diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c423f50 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: Test release package tooling + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - name: Run Python tests + run: python3 -m unittest discover -s tests + + - name: Shellcheck release scripts and composite actions + run: | + set -euo pipefail + python3 -c 'import yaml' 2>/dev/null || python3 -m pip install --quiet pyyaml + shellcheck --shell=bash --severity=info scripts/release/run-command.sh + python3 scripts/lint_action_shell.py + + - name: Install actionlint + shell: bash + run: | + set -euo pipefail + version=1.7.12 + archive="actionlint_${version}_linux_amd64.tar.gz" + checksums="actionlint_${version}_checksums.txt" + base_url="https://github.com/rhysd/actionlint/releases/download/v${version}" + curl -fsSLO "$base_url/$archive" + curl -fsSLO "$base_url/$checksums" + grep " $archive\$" "$checksums" > actionlint.sha256 + sha256sum -c actionlint.sha256 + tar -xzf "$archive" actionlint + echo "$PWD" >> "$GITHUB_PATH" + + - name: Lint workflows + run: | + actionlint + actionlint tests/fixtures/composite-actions.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41830ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +.release/ +actionlint diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a7698be --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Copyright © 2026 Luke Boswell and subsequent authors. + +The Universal Permissive License (UPL), Version 1.0 + +Subject to the condition set forth below, permission is hereby granted to any person obtaining a copy of this software, associated documentation and/or data (collectively the "Software"), free of charge and under any and all copyright rights in the Software, and any and all patent rights owned or freely licensable by each licensor hereunder covering either (i) the unmodified Software as contributed to or provided by such licensor, or (ii) the Larger Works (as defined below), to deal in both + +(a) the Software, and + +(b) any piece of software and/or hardware listed in the lrgrwrks.txt file if one is included with the Software (each a “Larger Work” to which the Software is contributed by such licensors), + +without restriction, including without limitation the rights to copy, create derivative works of, display, perform, and distribute the Software and make, use, sell, offer for sale, import, export, have made, and have sold the Software and the Larger Work(s), and to sublicense the foregoing rights on either these or other terms. + +This license is subject to the following condition: + +The above copyright notice and either this complete permission notice or at a minimum a reference to the UPL must be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 82da2fd..7ad6708 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,621 @@ +[![Roc-Lang][roc_badge]][roc_link] + +[roc_badge]: https://img.shields.io/endpoint?url=https%3A%2F%2Fpastebin.com%2Fraw%2FcFzuCCd7 +[roc_link]: https://github.com/roc-lang/roc + # release-package -A workflow for building, testing, and publishing Roc packages + +Composite GitHub Actions for Roc package and platform releases. + +`release-package` owns release mechanics: + +- strict `X.Y.Z` release validation +- tag and GitHub release availability checks +- previous bundle lookup for `roc bump` +- bundle manifest validation and test matrix generation +- release notes generation +- semver tag and GitHub release creation +- versioned docs safety checks + +Caller repositories own their jobs and environments: Roc, Zig, Rust, Node, Nix, +system packages, caches, services, runner labels, artifacts, and Pages deploys. +There is no reusable workflow wrapper. + +Install Zig only if your repo builds a Zig platform or otherwise needs Zig +during validation. Normal packages using prebuilt platform hosts do not need it. + +## Pinning + +Use actions directly from caller workflow steps: + +```yaml +- uses: roc-lang/release-package/actions/prepare-bundles@ +``` + +For release automation, prefer immutable refs: a release tag or a full commit +SHA. Use `@main` only for experiments where a moving ref is acceptable. + +## Actions + +- `validate-release`: validates release/docs versions and optionally checks tag/release availability +- `resolve-previous-release`: resolves an explicit or latest previous `.tar.zst` URL +- `run-bump-check`: runs `roc bump` with caller-installed Roc +- `prepare-bundles`: validates/copies bundles and outputs `test_matrix` plus `release_bundles` +- `test-bundle`: runs a caller command against one downloaded bundle +- `make-release-notes`: runs custom release notes command or generates default GitHub notes +- `publish-release`: creates the semver tag and GitHub release +- `docs-snapshot`: snapshots existing docs versions before generation +- `docs-index`: writes the docs root redirect/index +- `docs-validate`: validates generated docs and preserved historical docs + +`validate-release` supports PR validation with `dry_run: true`. If no +`release_version` is provided in dry-run mode, it uses `999.999.999` and skips +availability checks for that synthetic version; when a real `release_version` +is provided, availability is still checked even in dry-run mode so duplicate +versions fail before merge. It outputs `is_dry_run`, `release_base_version`, +and `is_prerelease`, so `1.2.3-rc1` can publish as `1.2.3-rc1` while release +availability checks still use the full prerelease tag and bump checks use +`1.2.3`. `run-bump-check` writes a skipped bump output in dry-run +mode without calling `roc`. + +Actions that call the GitHub API or `gh` accept `github_token`; pass +`${{ github.token }}` or provide `GH_TOKEN`/`GITHUB_TOKEN` in the job environment. + +Command inputs such as `test_bundle_command` and `release_notes_command` execute +as shell on the runner with the job environment. Treat them as trusted workflow +code only. Do not derive these strings from pull request text, issue comments, +release metadata, or untrusted workflow inputs. + +## Permissions + +| Workflow use | Permissions | +| --- | --- | +| Build or validate only | `contents: read` | +| Availability checks or previous-release lookup | `contents: read` plus a GitHub token available to `gh` | +| Default release notes (`make-release-notes` without a custom command) | `contents: write` (the `generate-notes` API requires write access) | +| Publish GitHub release | `contents: write` | +| Commit docs and deploy Pages | `contents: write`, `pages: write`, `id-token: write` | + +Prefer job-level permissions when only one job needs write access. + +## Minimal Package Release + +This template is for a Roc package using prebuilt platform hosts. It installs +Roc only. It validates PRs with dry-run release settings and publishes only from +manual `workflow_dispatch` runs. + +```yaml +name: Release + +on: + pull_request: + workflow_dispatch: + inputs: + release_version: + description: Release version, for example 1.2.3 or 1.0.0-rc1 + required: true + +permissions: + contents: read + +# Serialize release runs in one group; PR validation runs get per-ref groups +# so they never cancel a queued release run. +concurrency: + group: ${{ github.event_name == 'workflow_dispatch' && format('release-{0}', github.repository) || format('release-validate-{0}-{1}', github.repository, github.ref) }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + +jobs: + build: + runs-on: ubuntu-latest + outputs: + release_version: ${{ steps.validate.outputs.release_version }} + docs_version: ${{ steps.validate.outputs.docs_version }} + test_matrix: ${{ steps.bundles.outputs.test_matrix }} + steps: + - uses: actions/checkout@v4 + + - uses: roc-lang/setup-roc@ + with: + version: nightly-new-compiler + + - id: validate + uses: roc-lang/release-package/actions/validate-release@ + with: + release_version: ${{ github.event_name == 'workflow_dispatch' && inputs.release_version || '' }} + dry_run: ${{ github.event_name != 'workflow_dispatch' }} + github_token: ${{ github.token }} + + - run: ./ci/all_tests.sh + + - id: previous + if: ${{ github.event_name == 'workflow_dispatch' }} + uses: roc-lang/release-package/actions/resolve-previous-release@ + with: + github_token: ${{ github.token }} + + - uses: roc-lang/release-package/actions/run-bump-check@ + with: + release_version: ${{ steps.validate.outputs.release_version }} + dry_run: ${{ github.event_name != 'workflow_dispatch' }} + previous_url: ${{ steps.previous.outputs.previous_url }} + bump_check: require + bump_entrypoint: main.roc + + - run: ./scripts/bundle.sh --output-dir dist + + - id: bundles + uses: roc-lang/release-package/actions/prepare-bundles@ + with: + bundle_glob: dist/*.tar.zst + test_os_json: '["ubuntu-latest"]' + + - uses: actions/upload-artifact@v4 + with: + name: release-bundles + path: .release/bundles/* + if-no-files-found: error + + - uses: actions/upload-artifact@v4 + with: + name: release-metadata + path: | + .release/bump-output.txt + .release/previous-url.txt + .release/release-bundles.json + .release/test-matrix.json + if-no-files-found: error + + test-bundles: + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.build.outputs.test_matrix) }} + steps: + - uses: actions/checkout@v4 + + - uses: roc-lang/setup-roc@ + with: + version: nightly-new-compiler + + - uses: actions/download-artifact@v4 + with: + name: release-bundles + path: .release/test-bundles + + - uses: roc-lang/release-package/actions/test-bundle@ + with: + test_bundle_command: python3 ci/test_bundle_examples.py --bundle-path + bundle_path: .release/test-bundles/${{ matrix.artifact_file }} + bundle_name: ${{ matrix.bundle_name }} + release_version: ${{ needs.build.outputs.release_version }} + + publish: + if: ${{ github.event_name == 'workflow_dispatch' }} + needs: + - build + - test-bundles + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: release-bundles + path: .release/bundles + + - uses: actions/download-artifact@v4 + with: + name: release-metadata + path: .release + + - uses: roc-lang/release-package/actions/make-release-notes@ + with: + release_version: ${{ needs.build.outputs.release_version }} + github_token: ${{ github.token }} + + - uses: roc-lang/release-package/actions/publish-release@ + with: + release_version: ${{ needs.build.outputs.release_version }} + github_token: ${{ github.token }} +``` + +## Platform Release + +Platform repos often need Zig, Rust, C toolchains, system packages, caches, or +host-build steps. Keep those setup choices in the caller workflow before the +release-package actions. + +```yaml +name: Platform Release + +on: + pull_request: + workflow_dispatch: + inputs: + release_version: + description: Release version, for example 0.3.0 or 1.0.0-rc1 + required: true + +permissions: + contents: read + +# Serialize release runs in one group; PR validation runs get per-ref groups +# so they never cancel a queued release run. +concurrency: + group: ${{ github.event_name == 'workflow_dispatch' && format('release-{0}', github.repository) || format('release-validate-{0}-{1}', github.repository, github.ref) }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + +jobs: + build: + runs-on: ubuntu-latest + outputs: + release_version: ${{ steps.validate.outputs.release_version }} + docs_version: ${{ steps.validate.outputs.docs_version }} + test_matrix: ${{ steps.bundles.outputs.test_matrix }} + steps: + - uses: actions/checkout@v4 + + - uses: roc-lang/setup-roc@ + with: + version: nightly-new-compiler + + - uses: mlugg/setup-zig@ + with: + version: "0.16.0" + + - run: ./ci/setup_platform_build.sh + + - id: validate + uses: roc-lang/release-package/actions/validate-release@ + with: + release_version: ${{ github.event_name == 'workflow_dispatch' && inputs.release_version || '' }} + dry_run: ${{ github.event_name != 'workflow_dispatch' }} + github_token: ${{ github.token }} + + - run: ./ci/all_tests.sh + + - id: previous + if: ${{ github.event_name == 'workflow_dispatch' }} + uses: roc-lang/release-package/actions/resolve-previous-release@ + with: + github_token: ${{ github.token }} + + - uses: roc-lang/release-package/actions/run-bump-check@ + with: + release_version: ${{ steps.validate.outputs.release_version }} + dry_run: ${{ github.event_name != 'workflow_dispatch' }} + previous_url: ${{ steps.previous.outputs.previous_url }} + bump_check: require + bump_entrypoint: platform/main.roc + + - run: ./ci/bundle_platform.sh --output-dir dist + + - id: bundles + uses: roc-lang/release-package/actions/prepare-bundles@ + with: + bundle_glob: dist/*.tar.zst + bundle_manifest_path: dist/release-bundles.json + + - uses: actions/upload-artifact@v4 + with: + name: release-bundles + path: .release/bundles/* + if-no-files-found: error + + - uses: actions/upload-artifact@v4 + with: + name: release-metadata + path: | + .release/bump-output.txt + .release/previous-url.txt + .release/release-bundles.json + .release/test-matrix.json + if-no-files-found: error + + test-bundles: + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.build.outputs.test_matrix) }} + steps: + - uses: actions/checkout@v4 + + - uses: roc-lang/setup-roc@ + with: + version: nightly-new-compiler + + - uses: mlugg/setup-zig@ + with: + version: "0.16.0" + + - run: ./ci/setup_platform_test.sh + + - uses: actions/download-artifact@v4 + with: + name: release-bundles + path: .release/test-bundles + + - uses: roc-lang/release-package/actions/test-bundle@ + with: + test_bundle_command: bash ci/test_bundled_examples.sh + bundle_path: .release/test-bundles/${{ matrix.artifact_file }} + bundle_name: ${{ matrix.bundle_name }} + release_version: ${{ needs.build.outputs.release_version }} + + publish: + if: ${{ github.event_name == 'workflow_dispatch' }} + needs: + - build + - test-bundles + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: release-bundles + path: .release/bundles + + - uses: actions/download-artifact@v4 + with: + name: release-metadata + path: .release + + - uses: roc-lang/release-package/actions/make-release-notes@ + with: + release_version: ${{ needs.build.outputs.release_version }} + github_token: ${{ github.token }} + + - uses: roc-lang/release-package/actions/publish-release@ + with: + release_version: ${{ needs.build.outputs.release_version }} + github_token: ${{ github.token }} +``` + +## Docs Publishing + +Docs publishing is caller-owned. This job assumes a successful real release and +commits generated docs before deploying GitHub Pages. + +```yaml +docs: + if: ${{ github.event_name == 'workflow_dispatch' }} + needs: + - build + - publish + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - uses: roc-lang/setup-roc@ + with: + version: nightly-new-compiler + + - uses: roc-lang/release-package/actions/docs-snapshot@ + with: + docs_root: www + + - run: | + rm -rf "www/${{ needs.build.outputs.docs_version }}" + roc docs package/main.roc --output="www/${{ needs.build.outputs.docs_version }}" + + - uses: roc-lang/release-package/actions/docs-index@ + with: + docs_root: www + docs_version: ${{ needs.build.outputs.docs_version }} + + - uses: roc-lang/release-package/actions/docs-validate@ + with: + docs_root: www + docs_version: ${{ needs.build.outputs.docs_version }} + + - run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add www + if git diff --cached --quiet -- www; then + echo "Docs did not change." + else + git commit -m "Update docs for ${{ needs.build.outputs.release_version }}" + git push + fi + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: www + + - id: deployment + uses: actions/deploy-pages@v4 +``` + +The minimal release template exposes `release_version` and `docs_version` from +`build`, so docs jobs can depend on both `build` and `publish`: `publish` gates +deployment on a completed release, while `build` provides the version outputs. + +When your workflow publishes docs, also pass `docs_url` to `make-release-notes` +so the generated notes link to them, for example +`docs_url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${{ needs.build.outputs.docs_version }}/`. +Leave `docs_url` unset in workflows without a docs job so release notes do not +link to pages that were never published. It is fine for release notes to be +generated before the docs job deploys: the URL is deterministic and starts +working once Pages deployment finishes. + +In the `publish` job, the release-notes step then looks like: + +```yaml + - uses: roc-lang/release-package/actions/make-release-notes@ + with: + release_version: ${{ needs.build.outputs.release_version }} + github_token: ${{ github.token }} + docs_url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${{ needs.build.outputs.docs_version }}/ +``` + +## Release Candidates + +Release candidates use the same manual release workflow as stable releases. To +publish an RC, trigger `workflow_dispatch` with `release_version` set to the +full prerelease version, for example `1.2.3-rc1`. The prerelease suffix is the +only RC-specific input; do not add a separate workflow flag unless your caller +repo needs extra policy. + +For `1.2.3-rc1`, the actions behave this way: + +- `validate-release` validates `1.2.3-rc1`, checks that the matching tag and + GitHub release do not already exist, outputs `release_base_version=1.2.3`, + and sets `is_prerelease=true`. +- `resolve-previous-release` still resolves GitHub's latest stable release as + the previous bundle. `run-bump-check` passes the base version (`1.2.3`) to + `roc bump --expect`, so the API check compares the RC against the last stable + package rather than a previous RC. +- Bundles and bundle tests run the same way as a stable release. Test commands + receive `RELEASE_VERSION=1.2.3-rc1`. +- `make-release-notes` writes notes for the RC tag. When `docs_url` is set, the + notes include a direct link to the exact RC docs directory, such as + `https://example.github.io/package/1.2.3-rc1/`. +- `publish-release` creates tag `1.2.3-rc1` and marks the GitHub release as a + prerelease automatically. +- `docs-index` leaves the root docs redirect pointed at the current stable + release by default. RC docs can still be committed and deployed under + `www/1.2.3-rc1/`; users reach them from the direct release-notes link. + +If you publish multiple RCs, the default bump baseline remains the latest stable +release each time. Set `previous_release_url` explicitly only for an unusual +backport, recovery run, or workflow that intentionally compares one RC against +another. + +When the RC is accepted, run the same workflow with the stable version, for +example `1.2.3`. The stable release gets its own tag and GitHub release, docs +are generated under `www/1.2.3/`, and `docs-index` updates the root redirect to +the stable docs. + +## Artifact Conventions + +The `.release/*` paths are action defaults. Keep them unless your workflow has a +reason to override the corresponding action inputs. + +The GitHub artifact names `release-bundles` and `release-metadata` are +conventions used by these examples, not required API. If you rename them, update +the matching upload and download steps together. + +`prepare-bundles` writes: + +- `.release/bundles/*` +- `.release/test-matrix.json` +- `.release/release-bundles.json` + +`publish-release` defaults to `.release/release-bundles.json` as its asset +manifest and uploads only the listed `.tar.zst` files from `.release/bundles`. + +For release candidates such as `1.2.3-rc1`, `resolve-previous-release` still +uses GitHub's latest stable release as the default previous bundle. Set +`previous_release_url` explicitly for unusual backports or recovery workflows. + +With `bump_check: require`, `run-bump-check` fails when no previous release +bundle URL was resolved, so a wiring mistake cannot silently skip the check. +For a repository's first release (no previous release exists yet), use +`bump_check: warn` or `off` for that run. + +## Bundle Contract + +`prepare-bundles` expects one or more `.tar.zst` files matched by `bundle_glob`. +It fails if the glob matches nothing, paths escape the workspace, filenames +collide, a matched file is not a `.tar.zst`, a filename contains `#`, or any +bundle has no test runner. + +Generated files are written under the workspace. `bundle_dir` must not be `/`, +`$HOME`, the workspace root, or a directory containing the source bundles matched +by `bundle_glob`. `prepare-bundles` marks the `bundle_dir` it creates and refuses +to delete an existing non-empty directory it did not create. + +For a simple release, every matched bundle is tested on every runner in +`test_os_json`. + +For a multi-bundle release, write a manifest and set `bundle_manifest_path`: + +```json +[ + { + "name": "default", + "path": "dist/roc-ray-default.tar.zst", + "test_os": ["ubuntu-latest", "macos-latest", "windows-latest"] + }, + { + "name": "wayland", + "path": "dist/roc-ray-wayland.tar.zst", + "test_os": ["ubuntu-latest"] + } +] +``` + +`test-bundle` exposes: + +- `BUNDLE_NAME` +- `BUNDLE_PATH` (absolute, so test scripts may change directory) +- `RELEASE_VERSION` + +It also appends the absolute bundle path as the final argument to +`test_bundle_command`. Trailing whitespace in the command is trimmed, so YAML +block scalars work; a command whose last line ends in a `#` comment is +rejected because the comment would swallow the appended argument. + +## Docs Contract + +Docs are versioned under `docs_root`: + +```text +www/ + index.html + 1.2.0/ + 1.3.0-rc1/ + 1.3.0/ +``` + +The docs command should replace only the directory for the version being +published. `docs-validate` checks that `docs_root` exists, is non-empty, contains +`$DOCS_VERSION/index.html`, and did not remove older version directories that +existed before docs generation. + +Release-candidate docs can be published under their exact version directory, +for example `www/1.3.0-rc1/`. By default, `docs-index` does not update the root +redirect for prerelease docs; the redirect should move only when a stable release +is published. Generated release notes can include a direct RC docs link with the +`docs_url` input on `make-release-notes`. + +## Release Safety + +The helpers accept stable release versions such as `1.2.3` and prerelease +versions such as `1.2.3-rc1`. For prereleases, `roc bump --expect` receives the +base version, for example `1.2.3`. + +The helpers reject release versions such as: + +- `v1.2.3` +- `1.2` +- `1.2.3+build.1` +- `0.0.0` + +`validate-release` and `publish-release` can fail if the tag or GitHub release +already exists. `publish-release` does not overwrite releases or delete assets. +To recover from a failed or bad publish: + +1. Delete the GitHub release. +2. Delete the Git tag. +3. Fix the source problem. +4. Rerun the caller workflow with the same version. diff --git a/actions/docs-index/action.yml b/actions/docs-index/action.yml new file mode 100644 index 0000000..eaff579 --- /dev/null +++ b/actions/docs-index/action.yml @@ -0,0 +1,37 @@ +name: Update Roc docs index +description: Write a docs root redirect/index for the selected docs version. +inputs: + docs_root: + description: Directory containing generated docs. + default: www + docs_version: + description: Docs version. Empty uses DOCS_VERSION or RELEASE_VERSION env. + default: "" + repo_name: + description: Repository name for Pages redirect paths. Empty uses GITHUB_REPOSITORY. + default: "" + update_prerelease_index: + description: Whether prerelease docs should update the docs root redirect. + default: "false" +outputs: + index_file: + description: Docs root index file. Empty when the prerelease index update was skipped. + value: ${{ steps.index.outputs.index_file }} +runs: + using: composite + steps: + - id: index + shell: bash + env: + DOCS_ROOT: ${{ inputs.docs_root }} + DOCS_VERSION_INPUT: ${{ inputs.docs_version }} + REPO_NAME_INPUT: ${{ inputs.repo_name }} + UPDATE_PRERELEASE_INDEX: ${{ inputs.update_prerelease_index }} + run: | + set -euo pipefail + python3 "$GITHUB_ACTION_PATH/../../scripts/release/release.py" write-docs-index \ + --docs-root "$DOCS_ROOT" \ + --docs-version "$DOCS_VERSION_INPUT" \ + --repo-name "$REPO_NAME_INPUT" \ + --update-prerelease-index "$UPDATE_PRERELEASE_INDEX" \ + --github-output "$GITHUB_OUTPUT" diff --git a/actions/docs-snapshot/action.yml b/actions/docs-snapshot/action.yml new file mode 100644 index 0000000..53676aa --- /dev/null +++ b/actions/docs-snapshot/action.yml @@ -0,0 +1,22 @@ +name: Snapshot Roc docs +description: Snapshot existing versioned docs directories before docs generation. +inputs: + docs_root: + description: Directory containing generated docs. + default: www + snapshot_file: + description: File where existing docs versions are recorded. + default: .release/docs-before.json +runs: + using: composite + steps: + - id: snapshot + shell: bash + env: + DOCS_ROOT: ${{ inputs.docs_root }} + SNAPSHOT_FILE: ${{ inputs.snapshot_file }} + run: | + set -euo pipefail + python3 "$GITHUB_ACTION_PATH/../../scripts/release/release.py" snapshot-docs \ + --docs-root "$DOCS_ROOT" \ + --snapshot-file "$SNAPSHOT_FILE" diff --git a/actions/docs-validate/action.yml b/actions/docs-validate/action.yml new file mode 100644 index 0000000..4a38945 --- /dev/null +++ b/actions/docs-validate/action.yml @@ -0,0 +1,27 @@ +name: Validate Roc docs +description: Validate generated versioned docs and preserved historical docs. +inputs: + docs_root: + description: Directory containing generated docs. + default: www + docs_version: + description: Docs version. Empty uses DOCS_VERSION or RELEASE_VERSION env. + default: "" + snapshot_file: + description: File written by docs-snapshot. + default: .release/docs-before.json +runs: + using: composite + steps: + - id: validate + shell: bash + env: + DOCS_ROOT: ${{ inputs.docs_root }} + DOCS_VERSION_INPUT: ${{ inputs.docs_version }} + SNAPSHOT_FILE: ${{ inputs.snapshot_file }} + run: | + set -euo pipefail + python3 "$GITHUB_ACTION_PATH/../../scripts/release/release.py" validate-docs \ + --docs-root "$DOCS_ROOT" \ + --docs-version "$DOCS_VERSION_INPUT" \ + --snapshot-file "$SNAPSHOT_FILE" diff --git a/actions/make-release-notes/action.yml b/actions/make-release-notes/action.yml new file mode 100644 index 0000000..c1737b9 --- /dev/null +++ b/actions/make-release-notes/action.yml @@ -0,0 +1,99 @@ +name: Make Roc release notes +description: Generate default GitHub release notes or run a caller-provided notes command. +inputs: + release_version: + description: Release version. Empty uses RELEASE_VERSION env. + default: "" + release_notes_command: + description: Optional command that writes notes to release_notes_file. + default: "" + release_notes_file: + description: File where release notes are written. + default: .release/release-notes.md + bump_output_file: + description: File containing roc bump output. + default: .release/bump-output.txt + release_bundles_path: + description: File containing release bundle metadata for custom notes commands. + default: .release/release-bundles.json + docs_url: + description: Optional direct docs URL to include in generated notes and expose to custom notes commands. + default: "" + repo: + description: GitHub repository in owner/name form. Empty uses GITHUB_REPOSITORY. + default: "" + target: + description: Target commit-ish for generated release notes. Empty uses GITHUB_SHA. + default: "" + github_token: + description: Optional token for GitHub CLI calls. Empty uses existing GH_TOKEN/GITHUB_TOKEN env. + default: "" +outputs: + release_notes_file: + description: File containing release notes. + value: ${{ steps.notes.outputs.release_notes_file }} +runs: + using: composite + steps: + - id: notes + shell: bash + env: + RELEASE_VERSION_INPUT: ${{ inputs.release_version }} + RELEASE_NOTES_COMMAND: ${{ inputs.release_notes_command }} + RELEASE_NOTES_FILE: ${{ inputs.release_notes_file }} + BUMP_OUTPUT_FILE: ${{ inputs.bump_output_file }} + RELEASE_BUNDLES_PATH_INPUT: ${{ inputs.release_bundles_path }} + DOCS_URL: ${{ inputs.docs_url }} + REPO_INPUT: ${{ inputs.repo }} + TARGET_INPUT: ${{ inputs.target }} + GITHUB_TOKEN_INPUT: ${{ inputs.github_token }} + run: | + set -euo pipefail + + if [[ -n "$GITHUB_TOKEN_INPUT" ]]; then + export GH_TOKEN="$GITHUB_TOKEN_INPUT" + fi + + if [[ -n "$RELEASE_NOTES_COMMAND" ]]; then + release_version="$RELEASE_VERSION_INPUT" + if [[ -z "$release_version" ]]; then + release_version="${RELEASE_VERSION:-}" + fi + if [[ -z "$release_version" ]]; then + echo "release_version input or RELEASE_VERSION env is required." >&2 + exit 2 + fi + + notes_path="$RELEASE_NOTES_FILE" + if [[ "$notes_path" != /* ]]; then + notes_path="$PWD/$notes_path" + fi + bundles_path="$RELEASE_BUNDLES_PATH_INPUT" + if [[ "$bundles_path" != /* ]]; then + bundles_path="$PWD/$bundles_path" + fi + mkdir -p "$(dirname "$notes_path")" + + export RELEASE_NOTES_PATH="$notes_path" + export RELEASE_VERSION="$release_version" + export RELEASE_BUNDLES_PATH="$bundles_path" + export DOCS_URL + bash "$GITHUB_ACTION_PATH/../../scripts/release/run-command.sh" RELEASE_NOTES_COMMAND + else + python3 "$GITHUB_ACTION_PATH/../../scripts/release/release.py" make-release-notes \ + --repo "$REPO_INPUT" \ + --version "$RELEASE_VERSION_INPUT" \ + --target "$TARGET_INPUT" \ + --bump-output "$BUMP_OUTPUT_FILE" \ + --docs-url "$DOCS_URL" \ + --output-file "$RELEASE_NOTES_FILE" + fi + + if [[ ! -s "$RELEASE_NOTES_FILE" ]]; then + echo "Release notes file is missing or empty: $RELEASE_NOTES_FILE" >&2 + exit 1 + fi + python3 "$GITHUB_ACTION_PATH/../../scripts/release/release.py" append-github-output \ + --github-output "$GITHUB_OUTPUT" \ + --name release_notes_file \ + --value "$RELEASE_NOTES_FILE" diff --git a/actions/prepare-bundles/action.yml b/actions/prepare-bundles/action.yml new file mode 100644 index 0000000..607a701 --- /dev/null +++ b/actions/prepare-bundles/action.yml @@ -0,0 +1,55 @@ +name: Prepare Roc release bundles +description: Validate release bundle metadata, copy assets, and produce test matrix JSON. +inputs: + bundle_glob: + description: Glob for release artifacts. + default: dist/*.tar.zst + bundle_manifest_path: + description: Optional JSON manifest for multi-bundle releases. + default: "" + test_os_json: + description: JSON array of GitHub runner labels used when no manifest is provided. + default: '["ubuntu-latest","macos-latest","windows-latest"]' + workspace: + description: Workspace root. Empty uses GITHUB_WORKSPACE. + default: "" + bundle_dir: + description: Directory where validated release bundles are copied. + default: .release/bundles + matrix_file: + description: File where bundle test matrix JSON is written. + default: .release/test-matrix.json + release_list_file: + description: File where release bundle list JSON is written. + default: .release/release-bundles.json +outputs: + test_matrix: + description: Compact JSON matrix include list for bundle tests. + value: ${{ steps.prepare.outputs.test_matrix }} + release_bundles: + description: Compact JSON list of release bundle assets. + value: ${{ steps.prepare.outputs.release_bundles }} +runs: + using: composite + steps: + - id: prepare + shell: bash + env: + BUNDLE_GLOB: ${{ inputs.bundle_glob }} + BUNDLE_MANIFEST_PATH: ${{ inputs.bundle_manifest_path }} + TEST_OS_JSON: ${{ inputs.test_os_json }} + WORKSPACE_INPUT: ${{ inputs.workspace }} + BUNDLE_DIR: ${{ inputs.bundle_dir }} + MATRIX_FILE: ${{ inputs.matrix_file }} + RELEASE_LIST_FILE: ${{ inputs.release_list_file }} + run: | + set -euo pipefail + python3 "$GITHUB_ACTION_PATH/../../scripts/release/release.py" prepare-bundles \ + --bundle-glob "$BUNDLE_GLOB" \ + --bundle-manifest-path "$BUNDLE_MANIFEST_PATH" \ + --test-os-json "$TEST_OS_JSON" \ + --workspace "$WORKSPACE_INPUT" \ + --bundle-dir "$BUNDLE_DIR" \ + --matrix-file "$MATRIX_FILE" \ + --release-list-file "$RELEASE_LIST_FILE" \ + --github-output "$GITHUB_OUTPUT" diff --git a/actions/publish-release/action.yml b/actions/publish-release/action.yml new file mode 100644 index 0000000..16a8381 --- /dev/null +++ b/actions/publish-release/action.yml @@ -0,0 +1,54 @@ +name: Publish Roc release +description: Create the semver git tag and GitHub release from prepared bundles. +inputs: + release_version: + description: Release version. Empty uses RELEASE_VERSION env. + default: "" + release_notes_file: + description: Release notes file. + default: .release/release-notes.md + bundle_dir: + description: Directory containing release bundle assets. + default: .release/bundles + release_list_file: + description: File listing release bundle assets produced by prepare-bundles. + default: .release/release-bundles.json + repo: + description: GitHub repository in owner/name form. Empty uses GITHUB_REPOSITORY. + default: "" + target: + description: Target commit-ish for the tag and release. Empty uses GITHUB_SHA. + default: "" + check_availability: + description: Whether to re-check tag/release availability before publishing. + default: "true" + github_token: + description: Optional token for GitHub CLI calls. Empty uses existing GH_TOKEN/GITHUB_TOKEN env. + default: "" +runs: + using: composite + steps: + - id: publish + shell: bash + env: + RELEASE_VERSION_INPUT: ${{ inputs.release_version }} + RELEASE_NOTES_FILE: ${{ inputs.release_notes_file }} + BUNDLE_DIR: ${{ inputs.bundle_dir }} + RELEASE_LIST_FILE: ${{ inputs.release_list_file }} + REPO_INPUT: ${{ inputs.repo }} + TARGET_INPUT: ${{ inputs.target }} + CHECK_AVAILABILITY: ${{ inputs.check_availability }} + GITHUB_TOKEN_INPUT: ${{ inputs.github_token }} + run: | + set -euo pipefail + if [[ -n "$GITHUB_TOKEN_INPUT" ]]; then + export GH_TOKEN="$GITHUB_TOKEN_INPUT" + fi + python3 "$GITHUB_ACTION_PATH/../../scripts/release/release.py" publish-release \ + --version "$RELEASE_VERSION_INPUT" \ + --repo "$REPO_INPUT" \ + --target "$TARGET_INPUT" \ + --notes-file "$RELEASE_NOTES_FILE" \ + --bundle-dir "$BUNDLE_DIR" \ + --release-list-file "$RELEASE_LIST_FILE" \ + --check-availability "$CHECK_AVAILABILITY" diff --git a/actions/resolve-previous-release/action.yml b/actions/resolve-previous-release/action.yml new file mode 100644 index 0000000..df0d112 --- /dev/null +++ b/actions/resolve-previous-release/action.yml @@ -0,0 +1,39 @@ +name: Resolve previous Roc release +description: Resolve the previous stable release bundle URL for roc bump. +inputs: + previous_release_url: + description: Explicit previous release bundle URL. Empty means latest stable GitHub release asset. + default: "" + output_file: + description: File where the resolved URL is written. + default: .release/previous-url.txt + repo: + description: GitHub repository in owner/name form. Empty uses GITHUB_REPOSITORY. + default: "" + github_token: + description: Optional token for GitHub CLI calls. Empty uses existing GH_TOKEN/GITHUB_TOKEN env. + default: "" +outputs: + previous_url: + description: Resolved previous release bundle URL, or empty when none exists. + value: ${{ steps.resolve.outputs.previous_url }} +runs: + using: composite + steps: + - id: resolve + shell: bash + env: + PREVIOUS_RELEASE_URL: ${{ inputs.previous_release_url }} + OUTPUT_FILE: ${{ inputs.output_file }} + REPO_INPUT: ${{ inputs.repo }} + GITHUB_TOKEN_INPUT: ${{ inputs.github_token }} + run: | + set -euo pipefail + if [[ -n "$GITHUB_TOKEN_INPUT" ]]; then + export GH_TOKEN="$GITHUB_TOKEN_INPUT" + fi + python3 "$GITHUB_ACTION_PATH/../../scripts/release/release.py" resolve-previous-url \ + --provided-url "$PREVIOUS_RELEASE_URL" \ + --repo "$REPO_INPUT" \ + --output-file "$OUTPUT_FILE" \ + --github-output "$GITHUB_OUTPUT" diff --git a/actions/run-bump-check/action.yml b/actions/run-bump-check/action.yml new file mode 100644 index 0000000..623a07d --- /dev/null +++ b/actions/run-bump-check/action.yml @@ -0,0 +1,50 @@ +name: Run Roc bump check +description: Verify the requested release version with roc bump. +inputs: + release_version: + description: Strict semver release version. Empty uses RELEASE_VERSION env. + default: "" + dry_run: + description: Skip roc bump while still writing a bump output file. + default: "false" + bump_check: + description: How to handle roc bump verification. One of warn, require, or off. + default: require + bump_entrypoint: + description: Package entrypoint passed to roc bump. + default: main.roc + previous_url: + description: Previous bundle URL. Empty falls back to previous_url_file when present. + default: "" + previous_url_file: + description: File written by resolve-previous-release. + default: .release/previous-url.txt + output_file: + description: File where roc bump output is written. + default: .release/bump-output.txt +runs: + using: composite + steps: + - id: bump + shell: bash + env: + RELEASE_VERSION_INPUT: ${{ inputs.release_version }} + DRY_RUN: ${{ inputs.dry_run }} + BUMP_CHECK: ${{ inputs.bump_check }} + BUMP_ENTRYPOINT: ${{ inputs.bump_entrypoint }} + PREVIOUS_URL_INPUT: ${{ inputs.previous_url }} + PREVIOUS_URL_FILE: ${{ inputs.previous_url_file }} + OUTPUT_FILE: ${{ inputs.output_file }} + run: | + set -euo pipefail + previous_url="$PREVIOUS_URL_INPUT" + if [[ "$DRY_RUN" == "false" && -z "$previous_url" && -f "$PREVIOUS_URL_FILE" ]]; then + previous_url="$(cat "$PREVIOUS_URL_FILE")" + fi + python3 "$GITHUB_ACTION_PATH/../../scripts/release/release.py" run-bump-check \ + --mode "$BUMP_CHECK" \ + --version "$RELEASE_VERSION_INPUT" \ + --entrypoint "$BUMP_ENTRYPOINT" \ + --previous-url "$previous_url" \ + --output-file "$OUTPUT_FILE" \ + --dry-run "$DRY_RUN" diff --git a/actions/test-bundle/action.yml b/actions/test-bundle/action.yml new file mode 100644 index 0000000..951c9fb --- /dev/null +++ b/actions/test-bundle/action.yml @@ -0,0 +1,50 @@ +name: Test Roc release bundle +description: Run a caller-provided test command against one release bundle. +inputs: + test_bundle_command: + description: Command prefix that receives the absolute bundle path as its final argument. + required: true + bundle_path: + description: Path to the downloaded bundle artifact. + required: true + bundle_name: + description: Bundle name exposed as BUNDLE_NAME. + default: "" + release_version: + description: Release version exposed as RELEASE_VERSION. Empty uses RELEASE_VERSION env. + default: "" +runs: + using: composite + steps: + - id: test + shell: bash + env: + TEST_BUNDLE_COMMAND: ${{ inputs.test_bundle_command }} + BUNDLE_PATH_INPUT: ${{ inputs.bundle_path }} + BUNDLE_NAME_INPUT: ${{ inputs.bundle_name }} + RELEASE_VERSION_INPUT: ${{ inputs.release_version }} + run: | + set -euo pipefail + + bundle_path="$BUNDLE_PATH_INPUT" + if [[ ! -f "$bundle_path" ]]; then + echo "Bundle artifact is missing: $bundle_path" >&2 + exit 1 + fi + if [[ "$bundle_path" != /* ]]; then + bundle_path="$PWD/$bundle_path" + fi + + release_version="$RELEASE_VERSION_INPUT" + if [[ -z "$release_version" ]]; then + release_version="${RELEASE_VERSION:-}" + fi + if [[ -z "$release_version" ]]; then + echo "release_version input or RELEASE_VERSION env is required." >&2 + exit 2 + fi + + export BUNDLE_NAME="$BUNDLE_NAME_INPUT" + export BUNDLE_PATH="$bundle_path" + export RELEASE_VERSION="$release_version" + bash "$GITHUB_ACTION_PATH/../../scripts/release/run-command.sh" --append-args TEST_BUNDLE_COMMAND "$bundle_path" diff --git a/actions/validate-release/action.yml b/actions/validate-release/action.yml new file mode 100644 index 0000000..09bce8a --- /dev/null +++ b/actions/validate-release/action.yml @@ -0,0 +1,67 @@ +name: Validate Roc release +description: Validate release versions and optionally check tag/release availability. +inputs: + release_version: + description: Strict semver release version, for example 1.2.3. Required unless dry_run is true. + default: "" + dry_run: + description: Use dry-run validation defaults for pull requests and other non-publish checks. + default: "false" + dry_run_version: + description: Synthetic strict semver used when dry_run is true and release_version is empty. + default: 999.999.999 + docs_version: + description: Optional docs version. Empty means release_version. + default: "" + check_availability: + description: Whether to fail if the tag or GitHub release already exists. + default: "true" + repo: + description: GitHub repository in owner/name form. Empty uses GITHUB_REPOSITORY. + default: "" + github_token: + description: Optional token for GitHub CLI calls. Empty uses existing GH_TOKEN/GITHUB_TOKEN env. + default: "" +outputs: + release_version: + description: Validated release version. + value: ${{ steps.validate.outputs.release_version }} + docs_version: + description: Validated docs version. + value: ${{ steps.validate.outputs.docs_version }} + release_base_version: + description: Stable X.Y.Z base version used by roc bump. + value: ${{ steps.validate.outputs.release_base_version }} + is_dry_run: + description: Whether dry-run mode was used. + value: ${{ steps.validate.outputs.is_dry_run }} + is_prerelease: + description: Whether release_version has a prerelease suffix. + value: ${{ steps.validate.outputs.is_prerelease }} +runs: + using: composite + steps: + - id: validate + shell: bash + env: + RELEASE_VERSION_INPUT: ${{ inputs.release_version }} + DRY_RUN: ${{ inputs.dry_run }} + DRY_RUN_VERSION: ${{ inputs.dry_run_version }} + DOCS_VERSION_INPUT: ${{ inputs.docs_version }} + CHECK_AVAILABILITY: ${{ inputs.check_availability }} + REPO_INPUT: ${{ inputs.repo }} + GITHUB_TOKEN_INPUT: ${{ inputs.github_token }} + run: | + set -euo pipefail + if [[ -n "$GITHUB_TOKEN_INPUT" ]]; then + export GH_TOKEN="$GITHUB_TOKEN_INPUT" + fi + python3 "$GITHUB_ACTION_PATH/../../scripts/release/release.py" validate-release \ + --release-version "$RELEASE_VERSION_INPUT" \ + --dry-run "$DRY_RUN" \ + --dry-run-version "$DRY_RUN_VERSION" \ + --docs-version "$DOCS_VERSION_INPUT" \ + --check-availability "$CHECK_AVAILABILITY" \ + --repo "$REPO_INPUT" \ + --github-output "$GITHUB_OUTPUT" \ + --github-env "$GITHUB_ENV" diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ + diff --git a/scripts/lint_action_shell.py b/scripts/lint_action_shell.py new file mode 100644 index 0000000..3140a14 --- /dev/null +++ b/scripts/lint_action_shell.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Run shellcheck over the run: blocks of every composite action. + +actionlint only analyzes workflow files, so the bash embedded in +actions/*/action.yml gets no lint coverage without this script. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> int: + if shutil.which("shellcheck") is None: + print("error: shellcheck is not installed", file=sys.stderr) + return 2 + + action_files = sorted((ROOT / "actions").glob("*/action.yml")) + if not action_files: + print("error: no actions/*/action.yml files found", file=sys.stderr) + return 2 + + failures = 0 + for action_file in action_files: + data = yaml.safe_load(action_file.read_text(encoding="utf-8")) + steps = (data.get("runs") or {}).get("steps") or [] + for index, step in enumerate(steps): + script = step.get("run") + if not script: + continue + label = f"{action_file.relative_to(ROOT)} step {step.get('id', index)}" + if not check_script(script, sorted((step.get("env") or {}).keys()), label): + failures += 1 + + if failures: + print(f"error: shellcheck failed for {failures} action step(s)", file=sys.stderr) + return 1 + print(f"shellcheck passed for {len(action_files)} composite action(s)") + return 0 + + +def check_script(script: str, env_names: list[str], label: str) -> bool: + # Pre-declare the step's env: variables so shellcheck does not report + # them as unassigned (SC2154). + declarations = "".join(f': "${{{name}:=}}"\n' for name in env_names) + source = "#!/usr/bin/env bash\n" + declarations + script + with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False, encoding="utf-8") as handle: + handle.write(source) + script_path = handle.name + try: + result = subprocess.run( + ["shellcheck", "--shell=bash", "--severity=info", script_path], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + finally: + Path(script_path).unlink(missing_ok=True) + if result.returncode != 0: + print(f"--- {label}") + print(result.stdout) + return False + return True + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release/__init__.py b/scripts/release/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/scripts/release/__init__.py @@ -0,0 +1 @@ + diff --git a/scripts/release/release.py b/scripts/release/release.py new file mode 100644 index 0000000..a48a3a9 --- /dev/null +++ b/scripts/release/release.py @@ -0,0 +1,940 @@ +#!/usr/bin/env python3 +"""Release workflow helpers for Roc package releases.""" + +from __future__ import annotations + +import argparse +import glob +import html +import json +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +RELEASE_VERSION_RE = re.compile( + r"(?P" + r"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)" + r")(?:-(?P" + r"(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*" + r"))?" +) + +BUNDLE_SUFFIX = ".tar.zst" + +# Marker file that lets prepare-bundles recognize a bundle_dir it created, so +# it never deletes a directory holding files it does not own. +BUNDLE_DIR_MARKER = ".created-by-prepare-bundles" + +# make-release-notes decides whether a bump output file holds real roc bump +# output by these prefixes; the writers below and is_meaningful_bump_output +# must stay in sync through these constants. +BUMP_SKIP_PREFIX = "roc bump check skipped" +BUMP_NO_PREVIOUS_PREFIX = "No previous release bundle found;" + + +@dataclass(frozen=True) +class ReleaseVersion: + full: str + base: str + prerelease: str + + @property + def is_prerelease(self) -> bool: + return bool(self.prerelease) + + +class ReleaseError(Exception): + pass + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subcommands = parser.add_subparsers(dest="command", required=True) + + validate = subcommands.add_parser("validate-release") + validate.add_argument("--release-version", default="") + validate.add_argument("--dry-run", choices=["true", "false"], default="false") + validate.add_argument("--dry-run-version", default="999.999.999") + validate.add_argument("--docs-version", default="") + validate.add_argument("--check-availability", choices=["true", "false"], default="true") + validate.add_argument("--repo", default="") + validate.add_argument("--github-output", default="") + validate.add_argument("--github-env", default="") + validate.set_defaults(func=cmd_validate_release) + + available = subcommands.add_parser("check-availability") + available.add_argument("version") + available.add_argument("--repo", default="") + available.set_defaults(func=cmd_check_availability) + + previous = subcommands.add_parser("resolve-previous-url") + previous.add_argument("--provided-url", default="") + previous.add_argument("--repo", default="") + previous.add_argument("--output-file", required=True) + previous.add_argument("--github-output", default="") + previous.set_defaults(func=cmd_resolve_previous_url) + + bump = subcommands.add_parser("run-bump-check") + bump.add_argument("--mode", required=True, choices=["warn", "require", "off"]) + bump.add_argument("--version", default="") + bump.add_argument("--entrypoint", required=True) + bump.add_argument("--previous-url", default="") + bump.add_argument("--output-file", required=True) + bump.add_argument("--dry-run", choices=["true", "false"], default="false") + bump.set_defaults(func=cmd_run_bump_check) + + bundles = subcommands.add_parser("prepare-bundles") + bundles.add_argument("--bundle-glob", required=True) + bundles.add_argument("--bundle-manifest-path", default="") + bundles.add_argument("--test-os-json", required=True) + bundles.add_argument("--workspace", default="") + bundles.add_argument("--bundle-dir", required=True) + bundles.add_argument("--matrix-file", required=True) + bundles.add_argument("--release-list-file", required=True) + bundles.add_argument("--github-output", default="") + bundles.set_defaults(func=cmd_prepare_bundles) + + github_output = subcommands.add_parser("append-github-output") + github_output.add_argument("--github-output", required=True) + github_output.add_argument("--name", required=True) + github_output.add_argument("--value", required=True) + github_output.set_defaults(func=cmd_append_github_output) + + notes = subcommands.add_parser("make-release-notes") + notes.add_argument("--repo", default="") + notes.add_argument("--version", default="") + notes.add_argument("--target", default="") + notes.add_argument("--bump-output", required=True) + notes.add_argument("--output-file", required=True) + notes.add_argument("--docs-url", default="") + notes.set_defaults(func=cmd_make_release_notes) + + publish = subcommands.add_parser("publish-release") + publish.add_argument("--version", default="") + publish.add_argument("--repo", default="") + publish.add_argument("--target", default="") + publish.add_argument("--notes-file", required=True) + publish.add_argument("--bundle-dir", required=True) + publish.add_argument("--release-list-file", required=True) + publish.add_argument("--check-availability", choices=["true", "false"], default="true") + publish.set_defaults(func=cmd_publish_release) + + snapshot_docs = subcommands.add_parser("snapshot-docs") + snapshot_docs.add_argument("--docs-root", required=True) + snapshot_docs.add_argument("--snapshot-file", required=True) + snapshot_docs.set_defaults(func=cmd_snapshot_docs) + + docs_index = subcommands.add_parser("write-docs-index") + docs_index.add_argument("--docs-root", required=True) + docs_index.add_argument("--docs-version", default="") + docs_index.add_argument("--repo-name", default="") + docs_index.add_argument("--update-prerelease-index", choices=["true", "false"], default="false") + docs_index.add_argument("--github-output", default="") + docs_index.set_defaults(func=cmd_write_docs_index) + + validate_docs = subcommands.add_parser("validate-docs") + validate_docs.add_argument("--docs-root", required=True) + validate_docs.add_argument("--docs-version", default="") + validate_docs.add_argument("--snapshot-file", required=True) + validate_docs.set_defaults(func=cmd_validate_docs) + + return parser + + +def main() -> int: + args = build_parser().parse_args() + try: + return args.func(args) + except ReleaseError as err: + print(f"error: {err}", file=sys.stderr) + return 1 + + +def cmd_validate_release(args: argparse.Namespace) -> int: + dry_run = args.dry_run == "true" + version_text = args.release_version + used_dry_run_version = False + if not version_text and dry_run: + version_text = args.dry_run_version + used_dry_run_version = True + if not version_text: + raise ReleaseError("release_version is required unless dry_run is true") + + release_version = parse_release_version(version_text) + docs_version = parse_release_version(args.docs_version) if args.docs_version else release_version + + if args.check_availability != "true": + print("Skipping tag/release availability check because check_availability is false.") + elif used_dry_run_version: + print( + "Skipping tag/release availability check for synthetic dry-run version " + f"{release_version.full}." + ) + else: + check_availability(release_version.full, args.repo) + + is_prerelease = "true" if release_version.is_prerelease else "false" + if args.github_output: + outputs = { + "release_version": release_version.full, + "docs_version": docs_version.full, + "release_base_version": release_version.base, + "is_dry_run": "true" if dry_run else "false", + "is_prerelease": is_prerelease, + } + for name, value in outputs.items(): + append_github_output(args.github_output, name, value) + if args.github_env: + env_values = { + "RELEASE_VERSION": release_version.full, + "RELEASE_BASE_VERSION": release_version.base, + "IS_PRERELEASE": is_prerelease, + "DOCS_VERSION": docs_version.full, + } + for name, value in env_values.items(): + append_github_output(args.github_env, name, value) + return 0 + + +def cmd_check_availability(args: argparse.Namespace) -> int: + check_availability(args.version, args.repo) + return 0 + + +def check_availability(version_text: str, repo_text: str) -> None: + version = validate_release_version(version_text) + repo = require_repo(env_fallback(repo_text, "GITHUB_REPOSITORY")) + + local = run(["git", "rev-parse", "-q", "--verify", f"refs/tags/{version}"]) + if local.returncode == 0: + raise ReleaseError(f"git tag {version!r} already exists locally") + + remote = run(["git", "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{version}"]) + if remote.returncode == 0: + raise ReleaseError(f"git tag {version!r} already exists on origin") + if remote.returncode not in (2,): + raise ReleaseError(f"could not check remote tag {version!r}: {trim_output(remote)}") + + release = run(["gh", "api", f"repos/{repo}/releases/tags/{version}"]) + if release.returncode == 0: + raise ReleaseError(f"GitHub release {version!r} already exists") + if not is_github_not_found(release): + raise ReleaseError(f"could not check GitHub release {version!r}: {trim_output(release)}") + # GitHub also answers 404 for repos the token cannot see (or typo'd repo + # names), which would make this check pass vacuously. + require_repo_accessible(repo) + + +def cmd_resolve_previous_url(args: argparse.Namespace) -> int: + if args.provided_url: + previous_url = require_single_line(args.provided_url, "previous release URL") + write_previous_url(args, previous_url) + return 0 + + repo = require_repo(env_fallback(args.repo, "GITHUB_REPOSITORY")) + latest = run(["gh", "api", f"repos/{repo}/releases/latest"]) + if latest.returncode != 0: + if is_github_not_found(latest): + # Distinguish "no releases yet" from an inaccessible or typo'd + # repo, which GitHub also reports as 404. + require_repo_accessible(repo) + write_previous_url(args, "") + return 0 + raise ReleaseError(f"could not look up latest release: {trim_output(latest)}") + + try: + data = json.loads(latest.stdout) + except json.JSONDecodeError as err: + raise ReleaseError(f"latest release response was not valid JSON: {err}") from err + + assets = data.get("assets", []) + matches = [ + asset.get("browser_download_url", "") + for asset in assets + if str(asset.get("name", "")).endswith(BUNDLE_SUFFIX) + ] + matches = [url for url in matches if url] + if len(matches) > 1: + raise ReleaseError( + f"latest release has multiple {BUNDLE_SUFFIX} assets; set previous_release_url explicitly" + ) + + previous_url = matches[0] if matches else "" + if previous_url: + previous_url = require_single_line(previous_url, "previous release URL") + write_previous_url(args, previous_url) + return 0 + + +def write_previous_url(args: argparse.Namespace, previous_url: str) -> None: + write_text(args.output_file, previous_url + "\n") + if args.github_output: + append_github_output(args.github_output, "previous_url", previous_url) + + +def cmd_run_bump_check(args: argparse.Namespace) -> int: + version_text = env_fallback(args.version, "RELEASE_VERSION") + release_version = parse_release_version(version_text) + mode = args.mode + + if args.dry_run == "true": + write_text( + args.output_file, + f"{BUMP_SKIP_PREFIX} for dry-run release {release_version.full}.\n", + ) + return 0 + + if mode == "off": + write_text(args.output_file, f"{BUMP_SKIP_PREFIX} because bump_check is off.\n") + return 0 + + if not args.previous_url: + if mode == "require": + raise ReleaseError( + "bump_check is 'require' but no previous release bundle URL was resolved; " + "set previous_release_url explicitly, or use bump_check 'warn' or 'off' " + "for a first release" + ) + write_text(args.output_file, f"{BUMP_NO_PREVIOUS_PREFIX} roc bump check skipped.\n") + return 0 + + result = run( + [ + "roc", + "bump", + "--old", + args.previous_url, + "--expect", + release_version.base, + args.entrypoint, + ] + ) + output = trim_output(result) + if not output: + output = "roc bump completed without output." + write_text(args.output_file, output.rstrip() + "\n") + + if result.returncode == 0: + return 0 + + message = f"roc bump failed for expected version {release_version.base}." + if mode == "warn": + print(f"warning: {message}", file=sys.stderr) + print(output, file=sys.stderr) + return 0 + + print(output, file=sys.stderr) + raise ReleaseError(message) + + +def cmd_prepare_bundles(args: argparse.Namespace) -> int: + workspace = Path(env_fallback(args.workspace, "GITHUB_WORKSPACE") or ".").resolve() + bundle_dir = safe_workspace_output_path(args.bundle_dir, workspace, "bundle_dir") + matrix_file = safe_workspace_output_path(args.matrix_file, workspace, "matrix_file") + release_list_file = safe_workspace_output_path( + args.release_list_file, workspace, "release_list_file" + ) + + glob_paths = find_bundle_glob(args.bundle_glob, workspace) + if not glob_paths: + raise ReleaseError(f"bundle_glob matched no files: {args.bundle_glob}") + + for path in glob_paths: + if path.is_relative_to(bundle_dir): + raise ReleaseError("bundle_dir must not contain files matched by bundle_glob") + + if args.bundle_manifest_path: + bundles = bundles_from_manifest(args.bundle_manifest_path, workspace) + manifest_paths = {bundle["path"] for bundle in bundles} + glob_path_set = set(glob_paths) + missing_manifest = sorted(glob_path_set - manifest_paths) + extra_manifest = sorted(manifest_paths - glob_path_set) + if missing_manifest: + raise ReleaseError( + "bundle_glob matched files without manifest test entries: " + + ", ".join(str(path.relative_to(workspace)) for path in missing_manifest) + ) + if extra_manifest: + raise ReleaseError( + "bundle manifest references files not matched by bundle_glob: " + + ", ".join(str(path.relative_to(workspace)) for path in extra_manifest) + ) + else: + test_os = parse_test_os_json(args.test_os_json) + bundles = default_bundles(glob_paths, test_os) + + artifact_names: set[str] = set() + matrix: list[dict[str, str]] = [] + release_list: list[dict[str, str]] = [] + + if bundle_dir.exists() and not bundle_dir.is_dir(): + raise ReleaseError(f"bundle_dir exists but is not a directory: {bundle_dir}") + if bundle_dir.is_dir(): + entries = list(bundle_dir.iterdir()) + if entries and not (bundle_dir / BUNDLE_DIR_MARKER).is_file(): + raise ReleaseError( + "bundle_dir already exists and was not created by prepare-bundles; " + f"refusing to delete it: {bundle_dir}" + ) + shutil.rmtree(bundle_dir) + bundle_dir.mkdir(parents=True, exist_ok=True) + (bundle_dir / BUNDLE_DIR_MARKER).write_text("", encoding="utf-8") + + for bundle in bundles: + source = bundle["path"] + artifact_file = validate_artifact_filename(source.name) + if artifact_file in artifact_names: + raise ReleaseError(f"duplicate release asset filename: {artifact_file}") + artifact_names.add(artifact_file) + shutil.copy2(source, bundle_dir / artifact_file) + + release_list.append( + { + "name": bundle["name"], + "artifact_file": artifact_file, + "source_path": str(source.relative_to(workspace)), + } + ) + for runner in bundle["test_os"]: + matrix.append( + { + "bundle_name": bundle["name"], + "artifact_file": artifact_file, + "os": runner, + } + ) + + if not matrix: + raise ReleaseError("no bundle test matrix entries were produced") + + write_json(matrix_file, matrix) + write_json(release_list_file, release_list) + + if args.github_output: + append_github_output(args.github_output, "test_matrix", compact_json(matrix)) + append_github_output(args.github_output, "release_bundles", compact_json(release_list)) + + return 0 + + +def cmd_append_github_output(args: argparse.Namespace) -> int: + append_github_output(args.github_output, args.name, args.value) + return 0 + + +def cmd_make_release_notes(args: argparse.Namespace) -> int: + version = validate_release_version(env_fallback(args.version, "RELEASE_VERSION")) + repo = require_repo(env_fallback(args.repo, "GITHUB_REPOSITORY")) + target = require_target(env_fallback(args.target, "GITHUB_SHA")) + docs_url = require_single_line(args.docs_url, "docs URL") if args.docs_url else "" + result = run( + [ + "gh", + "api", + f"repos/{repo}/releases/generate-notes", + "--method", + "POST", + "-f", + f"tag_name={version}", + "-f", + f"target_commitish={target}", + ] + ) + if result.returncode != 0: + raise ReleaseError(f"could not generate GitHub release notes: {trim_output(result)}") + + try: + generated = json.loads(result.stdout) + except json.JSONDecodeError as err: + raise ReleaseError(f"release notes response was not valid JSON: {err}") from err + + body = str(generated.get("body", "")).strip() + if not body: + body = f"Release {version}" + + bump_output = Path(args.bump_output) + if bump_output.is_file(): + bump_text = bump_output.read_text(encoding="utf-8").strip() + if is_meaningful_bump_output(bump_text): + body += "\n\n## Roc API Changes\n\n" + markdown_code_block(bump_text) + + if docs_url: + body += f"\n\n## Docs\n\n- [View docs for {version}]({docs_url})" + + write_text(args.output_file, body.rstrip() + "\n") + return 0 + + +def cmd_publish_release(args: argparse.Namespace) -> int: + release_version = parse_release_version(env_fallback(args.version, "RELEASE_VERSION")) + version = release_version.full + repo = require_repo(env_fallback(args.repo, "GITHUB_REPOSITORY")) + target = require_target(env_fallback(args.target, "GITHUB_SHA")) + notes_file = require_nonempty_file(args.notes_file, "release notes file") + assets = release_assets(args.bundle_dir, args.release_list_file) + + if args.check_availability == "true": + check_availability(version, repo) + + run_required( + ["git", "config", "user.name", "github-actions[bot]"], + "could not configure git user.name", + ) + run_required( + [ + "git", + "config", + "user.email", + "41898282+github-actions[bot]@users.noreply.github.com", + ], + "could not configure git user.email", + ) + run_required(["git", "tag", version, target], f"could not create git tag {version!r}") + run_required( + ["git", "push", "origin", f"refs/tags/{version}"], + f"could not push git tag {version!r}", + ) + create_release = [ + "gh", + "release", + "create", + version, + *[str(asset) for asset in assets], + "--repo", + repo, + "--target", + target, + "--title", + version, + "--notes-file", + str(notes_file), + ] + if release_version.is_prerelease: + create_release.append("--prerelease") + run_required(create_release, f"could not create GitHub release {version!r}") + return 0 + + +def cmd_snapshot_docs(args: argparse.Namespace) -> int: + docs_root = Path(args.docs_root) + versions = sorted(version_dirs(docs_root)) + write_json(args.snapshot_file, versions) + return 0 + + +def cmd_write_docs_index(args: argparse.Namespace) -> int: + docs_version_text = env_fallback(args.docs_version, "DOCS_VERSION", "RELEASE_VERSION") + if not docs_version_text: + raise ReleaseError( + "docs version is required (pass --docs-version or set DOCS_VERSION/RELEASE_VERSION)" + ) + release_version = parse_release_version(docs_version_text) + docs_version = release_version.full + if release_version.is_prerelease and args.update_prerelease_index != "true": + print(f"Skipping docs index update for prerelease docs version {docs_version}.") + return 0 + docs_root = Path(args.docs_root) + repo_name = clean_repo_name(env_fallback(args.repo_name, "GITHUB_REPOSITORY")) + target = f"/{repo_name}/{docs_version}/" + escaped_target = html.escape(target, quote=True) + content = ( + "\n" + '\n' + "\n" + ' \n' + f' \n' + f' \n' + f" Redirecting to {html.escape(docs_version)}\n" + "\n" + "\n" + f'

Redirecting to {html.escape(docs_version)}

\n' + "\n" + "\n" + ) + docs_root.mkdir(parents=True, exist_ok=True) + index_file = docs_root / "index.html" + write_text(index_file, content) + if args.github_output: + append_github_output(args.github_output, "index_file", str(index_file)) + return 0 + + +def cmd_validate_docs(args: argparse.Namespace) -> int: + docs_version_text = env_fallback(args.docs_version, "DOCS_VERSION", "RELEASE_VERSION") + if not docs_version_text: + raise ReleaseError( + "docs version is required (pass --docs-version or set DOCS_VERSION/RELEASE_VERSION)" + ) + docs_version = validate_release_version(docs_version_text) + docs_root = Path(args.docs_root) + if not docs_root.is_dir(): + raise ReleaseError(f"docs_root does not exist: {docs_root}") + if not any(docs_root.iterdir()): + raise ReleaseError(f"docs_root is empty: {docs_root}") + + version_root = docs_root / docs_version + if not version_root.is_dir(): + raise ReleaseError(f"generated docs version directory is missing: {version_root}") + if not (version_root / "index.html").is_file(): + raise ReleaseError(f"generated docs version index is missing: {version_root / 'index.html'}") + + before = set(read_json_file(args.snapshot_file, "docs snapshot file")) + after = set(version_dirs(docs_root)) + missing = sorted(version for version in before if version != docs_version and version not in after) + if missing: + raise ReleaseError( + "docs generation removed historical version directories: " + ", ".join(missing) + ) + + return 0 + + +def validate_release_version(version: str) -> str: + return parse_release_version(version).full + + +def parse_release_version(version: str) -> ReleaseVersion: + if not version: + raise ReleaseError("release version is required") + match = RELEASE_VERSION_RE.fullmatch(version) + if not match: + raise ReleaseError( + "release version must be semver X.Y.Z with optional prerelease, " + f"without a leading v or build metadata: {version!r}" + ) + parts = ( + int(match.group("major")), + int(match.group("minor")), + int(match.group("patch")), + ) + if parts == (0, 0, 0): + raise ReleaseError("release version 0.0.0 is reserved and cannot be published") + return ReleaseVersion( + full=version, + base=match.group("base"), + prerelease=match.group("prerelease") or "", + ) + + +def parse_test_os_json(value: str) -> list[str]: + try: + parsed = json.loads(value) + except json.JSONDecodeError as err: + raise ReleaseError(f"test_os_json is not valid JSON: {err}") from err + if not isinstance(parsed, list) or not parsed: + raise ReleaseError("test_os_json must be a non-empty JSON array") + runners: list[str] = [] + for item in parsed: + if not isinstance(item, str) or not item: + raise ReleaseError("test_os_json entries must be non-empty strings") + runners.append(item) + return runners + + +def find_bundle_glob(pattern: str, workspace: Path) -> list[Path]: + if "\n" in pattern or "\r" in pattern: + raise ReleaseError("bundle_glob must not contain newlines") + if Path(pattern).is_absolute(): + raise ReleaseError("bundle_glob must be relative to the workspace") + # Escape the workspace prefix so glob metacharacters in the checkout path + # itself (for example "build [linux-2]") are matched literally. + matches = glob.glob(os.path.join(glob.escape(str(workspace)), pattern), recursive=True) + paths = [safe_existing_file(path, workspace) for path in matches] + return sorted(set(paths)) + + +def bundles_from_manifest(manifest_path: str, workspace: Path) -> list[dict[str, Any]]: + manifest_file = safe_existing_file(manifest_path, workspace) + try: + data = json.loads(manifest_file.read_text(encoding="utf-8")) + except json.JSONDecodeError as err: + raise ReleaseError(f"bundle manifest is not valid JSON: {err}") from err + + if not isinstance(data, list) or not data: + raise ReleaseError("bundle manifest must be a non-empty JSON array") + + names: set[str] = set() + bundles: list[dict[str, Any]] = [] + for index, item in enumerate(data): + if not isinstance(item, dict): + raise ReleaseError(f"bundle manifest entry {index} must be an object") + name = item.get("name") + path = item.get("path") + test_os = item.get("test_os") + if not isinstance(name, str) or not name: + raise ReleaseError(f"bundle manifest entry {index} has invalid name") + if "\n" in name or "\r" in name: + raise ReleaseError(f"bundle manifest entry {name!r} contains a newline") + if name in names: + raise ReleaseError(f"duplicate bundle name in manifest: {name}") + names.add(name) + if not isinstance(path, str) or not path: + raise ReleaseError(f"bundle manifest entry {name!r} has invalid path") + if not isinstance(test_os, list) or not test_os: + raise ReleaseError(f"bundle manifest entry {name!r} must have non-empty test_os") + runners: list[str] = [] + for runner in test_os: + if not isinstance(runner, str) or not runner: + raise ReleaseError(f"bundle manifest entry {name!r} has invalid test_os runner") + runners.append(runner) + bundles.append( + { + "name": name, + "path": safe_existing_file(path, workspace), + "test_os": runners, + } + ) + + return bundles + + +def default_bundles(paths: list[Path], test_os: list[str]) -> list[dict[str, Any]]: + bundles: list[dict[str, Any]] = [] + for path in paths: + name = "default" if len(paths) == 1 else path.name.removesuffix(BUNDLE_SUFFIX) + bundles.append({"name": name, "path": path, "test_os": test_os}) + return bundles + + +def validate_artifact_filename(artifact_file: str) -> str: + require_single_line(artifact_file, "release artifact filename") + if "/" in artifact_file or "\\" in artifact_file or Path(artifact_file).name != artifact_file: + raise ReleaseError(f"release artifact filename must not contain directories: {artifact_file}") + if "#" in artifact_file: + # gh release create parses "path#label", so '#' would split the + # filename into a path and a display label at publish time. + raise ReleaseError(f"release artifact filename must not contain '#': {artifact_file}") + if not artifact_file.endswith(BUNDLE_SUFFIX): + raise ReleaseError( + f"release artifact filename must end with {BUNDLE_SUFFIX}: {artifact_file}" + ) + return artifact_file + + +def safe_existing_file(path_text: str | Path, workspace: Path) -> Path: + text = str(path_text) + if "\n" in text or "\r" in text: + raise ReleaseError(f"path contains a newline: {text!r}") + path = Path(text) + candidate = path if path.is_absolute() else workspace / path + resolved = candidate.resolve() + workspace_resolved = workspace.resolve() + if not resolved.is_relative_to(workspace_resolved): + raise ReleaseError(f"path escapes the workspace: {text}") + if not resolved.is_file(): + raise ReleaseError(f"file does not exist: {text}") + return resolved + + +def version_dirs(docs_root: Path) -> list[str]: + if not docs_root.is_dir(): + return [] + return [ + child.name + for child in docs_root.iterdir() + if child.is_dir() and is_release_version_dir(child.name) + ] + + +def is_release_version_dir(name: str) -> bool: + try: + parse_release_version(name) + return True + except ReleaseError: + return False + + +def clean_repo_name(repo_name: str) -> str: + if "/" in repo_name: + repo_name = repo_name.rsplit("/", 1)[1] + if not repo_name or "\n" in repo_name or "\r" in repo_name or "/" in repo_name: + raise ReleaseError(f"invalid repository name for docs redirect: {repo_name!r}") + return repo_name + + +def is_meaningful_bump_output(text: str) -> bool: + if not text: + return False + skipped_prefixes = (BUMP_SKIP_PREFIX, BUMP_NO_PREVIOUS_PREFIX) + return not any(text.startswith(prefix) for prefix in skipped_prefixes) + + +def markdown_code_block(text: str, info: str = "text") -> str: + longest = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0) + fence = "`" * max(3, longest + 1) + return f"{fence}{info}\n{text}\n{fence}" + + +def env_fallback(value: str, *env_names: str) -> str: + if value: + return value + for name in env_names: + env_value = os.environ.get(name, "") + if env_value: + return env_value + return "" + + +def require_repo(repo: str) -> str: + if not repo or "/" not in repo or "\n" in repo or "\r" in repo: + raise ReleaseError("GITHUB_REPOSITORY or --repo must be set to owner/name") + return repo + + +def require_repo_accessible(repo: str) -> None: + result = run(["gh", "api", f"repos/{repo}"]) + if result.returncode != 0: + raise ReleaseError( + f"cannot access repository {repo!r}; check the repository name and token " + f"permissions: {trim_output(result)}" + ) + + +def require_target(target: str) -> str: + if not target or "\n" in target or "\r" in target: + raise ReleaseError("GITHUB_SHA or --target must be set to a commit-ish") + return target + + +def require_single_line(value: str, description: str) -> str: + if "\n" in value or "\r" in value: + raise ReleaseError(f"{description} must not contain newlines") + return value + + +def require_nonempty_file(path_text: str | Path, description: str) -> Path: + path = Path(path_text) + if not path.is_file(): + raise ReleaseError(f"{description} is missing: {path}") + if path.stat().st_size == 0: + raise ReleaseError(f"{description} is empty: {path}") + return path + + +def safe_workspace_output_path(path_text: str | Path, workspace: Path, description: str) -> Path: + text = require_single_line(str(path_text), description) + if not text: + raise ReleaseError(f"{description} is required") + + path = Path(text) + candidate = path if path.is_absolute() else workspace / path + resolved = candidate.resolve(strict=False) + workspace_resolved = workspace.resolve() + + if not resolved.is_relative_to(workspace_resolved): + raise ReleaseError(f"{description} must resolve inside the workspace: {text}") + if resolved == workspace_resolved: + raise ReleaseError(f"{description} must not be the workspace root") + if resolved == Path(resolved.anchor).resolve(): + raise ReleaseError(f"{description} must not be the filesystem root") + if resolved == Path.home().resolve(): + raise ReleaseError(f"{description} must not be the home directory") + + return resolved + + +def release_assets(bundle_dir_text: str | Path, release_list_file_text: str | Path) -> list[Path]: + bundle_dir = Path(bundle_dir_text) + if not bundle_dir.is_dir(): + raise ReleaseError(f"release bundle directory is missing: {bundle_dir}") + + require_nonempty_file(release_list_file_text, "release bundle list file") + release_list = read_json_file(release_list_file_text, "release bundle list file") + if not isinstance(release_list, list) or not release_list: + raise ReleaseError("release bundle list file must be a non-empty JSON array") + + seen: set[str] = set() + assets: list[Path] = [] + for index, item in enumerate(release_list): + if not isinstance(item, dict): + raise ReleaseError(f"release bundle list entry {index} must be an object") + artifact_file = item.get("artifact_file") + if not isinstance(artifact_file, str) or not artifact_file: + raise ReleaseError(f"release bundle list entry {index} has invalid artifact_file") + validate_artifact_filename(artifact_file) + if artifact_file in seen: + raise ReleaseError(f"duplicate release artifact filename: {artifact_file}") + seen.add(artifact_file) + assets.append(bundle_dir / artifact_file) + + for asset in assets: + if not asset.is_file(): + raise ReleaseError(f"release asset is missing: {asset}") + return assets + + +def run(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def run_required(command: list[str], message: str) -> None: + result = run(command) + if result.returncode != 0: + raise ReleaseError(f"{message}: {trim_output(result)}") + + +def is_github_not_found(result: subprocess.CompletedProcess[str]) -> bool: + if result.returncode == 0: + return False + # gh api prints the API's JSON error body on stdout; prefer its status + # field over substring-matching arbitrary output. + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError: + payload = None + if isinstance(payload, dict) and str(payload.get("status", "")) == "404": + return True + return "HTTP 404" in result.stderr + + +def trim_output(result: subprocess.CompletedProcess[str]) -> str: + return (result.stdout + result.stderr).strip() + + +def write_text(path: str | Path, content: str) -> None: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + +def write_json(path: str | Path, data: Any) -> None: + write_text(path, json.dumps(data, indent=2, sort_keys=True) + "\n") + + +def read_json(path: str | Path) -> Any: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def read_json_file(path: str | Path, description: str) -> Any: + try: + return read_json(path) + except FileNotFoundError as err: + raise ReleaseError(f"{description} is missing: {path}") from err + except json.JSONDecodeError as err: + raise ReleaseError(f"{description} is not valid JSON: {err}") from err + + +def compact_json(data: Any) -> str: + return json.dumps(data, separators=(",", ":"), sort_keys=True) + + +def append_github_output(path: str | Path, name: str, value: str) -> None: + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", name): + raise ReleaseError(f"invalid GitHub output name: {name!r}") + require_single_line(value, f"GitHub output {name}") + with open(path, "a", encoding="utf-8") as handle: + handle.write(f"{name}={value}\n") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release/run-command.sh b/scripts/release/run-command.sh new file mode 100644 index 0000000..13c917d --- /dev/null +++ b/scripts/release/run-command.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "usage: run-command.sh [--append-args] ENV_VAR_NAME [args...]" >&2 +} + +append_args=false +if [[ "${1:-}" == "--append-args" ]]; then + append_args=true + shift +fi + +if [[ $# -lt 1 ]]; then + usage + exit 2 +fi + +env_name="$1" +shift + +command="${!env_name:-}" +if [[ -z "${command//[$' \t\r\n']/}" ]]; then + echo "error: $env_name must contain a command." >&2 + exit 2 +fi + +if [[ "$append_args" == true ]]; then + # Trim trailing whitespace so commands from YAML block scalars (which end + # with a newline) keep the appended arguments on the same command line. + while [[ "$command" == *$'\n' || "$command" == *$'\r' || "$command" == *' ' || "$command" == *$'\t' ]]; do + command="${command%?}" + done + last_line="${command##*$'\n'}" + if [[ "$last_line" == '#'* || "$last_line" == *' #'* || "$last_line" == *$'\t#'* ]]; then + echo "error: $env_name ends with a '#' comment, which would swallow the appended arguments." >&2 + exit 2 + fi +fi + +tmp_dir="${RUNNER_TEMP:-/tmp}" +# Keep the XXXXXX template at the end: BSD mktemp does not substitute +# non-trailing X's and would create a fixed, predictable filename. +command_file="$(mktemp "$tmp_dir/release-command.XXXXXX")" +cleanup() { + rm -f "$command_file" +} +trap cleanup EXIT + +{ + echo "set -euo pipefail" + if [[ "$append_args" == true ]]; then + printf "%s" "$command" + for arg in "$@"; do + printf " %q" "$arg" + done + printf "\n" + else + printf "%s\n" "$command" + fi +} > "$command_file" + +bash "$command_file" diff --git a/tests/fixtures/composite-actions.yml b/tests/fixtures/composite-actions.yml new file mode 100644 index 0000000..bd0670c --- /dev/null +++ b/tests/fixtures/composite-actions.yml @@ -0,0 +1,108 @@ +name: Composite action fixture + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + outputs: + release_version: ${{ steps.validate.outputs.release_version }} + docs_version: ${{ steps.validate.outputs.docs_version }} + test_matrix: ${{ steps.bundles.outputs.test_matrix }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - id: validate + uses: ./actions/validate-release + with: + dry_run: "true" + + - id: previous + uses: ./actions/resolve-previous-release + with: + previous_release_url: https://example.com/pkg.tar.zst + + - uses: ./actions/run-bump-check + with: + release_version: ${{ steps.validate.outputs.release_version }} + dry_run: "true" + bump_check: require + + - run: | + mkdir -p dist + printf 'bundle' > dist/pkg.tar.zst + + - id: bundles + uses: ./actions/prepare-bundles + with: + test_os_json: '["ubuntu-latest"]' + + test-bundles: + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.build.outputs.test_matrix) }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - run: | + mkdir -p .release/test-bundles + printf 'bundle' > ".release/test-bundles/${{ matrix.artifact_file }}" + + - uses: ./actions/test-bundle + with: + test_bundle_command: test -f + bundle_path: .release/test-bundles/${{ matrix.artifact_file }} + bundle_name: ${{ matrix.bundle_name }} + release_version: ${{ needs.build.outputs.release_version }} + + publish: + needs: + - build + - test-bundles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - run: | + mkdir -p .release/bundles + printf 'bundle' > .release/bundles/pkg.tar.zst + printf '[{"name":"default","artifact_file":"pkg.tar.zst","source_path":"dist/pkg.tar.zst"}]\n' > .release/release-bundles.json + + - uses: ./actions/make-release-notes + with: + release_version: ${{ needs.build.outputs.release_version }} + release_notes_command: | + printf 'Release notes\n' > "$RELEASE_NOTES_PATH" + + - uses: ./actions/publish-release + with: + release_version: ${{ needs.build.outputs.release_version }} + check_availability: "false" + + docs: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - uses: ./actions/docs-snapshot + + - run: | + mkdir -p "www/${{ needs.build.outputs.docs_version }}" + printf '\n' > "www/${{ needs.build.outputs.docs_version }}/index.html" + + - uses: ./actions/docs-index + with: + docs_version: ${{ needs.build.outputs.docs_version }} + repo_name: example + + - uses: ./actions/docs-validate + with: + docs_version: ${{ needs.build.outputs.docs_version }} diff --git a/tests/test_action_metadata.py b/tests/test_action_metadata.py new file mode 100644 index 0000000..c26a198 --- /dev/null +++ b/tests/test_action_metadata.py @@ -0,0 +1,73 @@ +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +EXPECTED_ACTIONS = { + "docs-index", + "docs-snapshot", + "docs-validate", + "make-release-notes", + "prepare-bundles", + "publish-release", + "resolve-previous-release", + "run-bump-check", + "test-bundle", + "validate-release", +} + + +class ActionMetadataTest(unittest.TestCase): + def test_expected_composite_actions_exist(self): + action_dirs = { + path.parent.name + for path in (ROOT / "actions").glob("*/action.yml") + } + self.assertEqual(action_dirs, EXPECTED_ACTIONS) + + def test_actions_are_composite_and_have_steps(self): + for action in sorted(EXPECTED_ACTIONS): + with self.subTest(action=action): + text = (ROOT / "actions" / action / "action.yml").read_text(encoding="utf-8") + self.assertIn("runs:\n using: composite\n steps:", text) + self.assertIn("shell: bash", text) + + def test_old_reusable_release_workflow_is_removed(self): + self.assertFalse((ROOT / ".github" / "workflows" / "roc-release.yml").exists()) + + def test_actionlint_fixture_references_every_action(self): + fixture = (ROOT / "tests" / "fixtures" / "composite-actions.yml").read_text( + encoding="utf-8" + ) + for action in sorted(EXPECTED_ACTIONS): + with self.subTest(action=action): + self.assertIn(f"./actions/{action}", fixture) + + def test_dry_run_action_inputs_are_documented(self): + validate = (ROOT / "actions" / "validate-release" / "action.yml").read_text( + encoding="utf-8" + ) + bump = (ROOT / "actions" / "run-bump-check" / "action.yml").read_text( + encoding="utf-8" + ) + self.assertIn("dry_run:", validate) + self.assertIn("dry_run_version:", validate) + self.assertIn("is_dry_run:", validate) + self.assertIn("release_base_version:", validate) + self.assertIn("is_prerelease:", validate) + self.assertIn("dry_run:", bump) + self.assertIn("--dry-run", bump) + + def test_prerelease_related_action_inputs_are_documented(self): + notes = (ROOT / "actions" / "make-release-notes" / "action.yml").read_text( + encoding="utf-8" + ) + docs_index = (ROOT / "actions" / "docs-index" / "action.yml").read_text( + encoding="utf-8" + ) + self.assertIn("docs_url:", notes) + self.assertIn("update_prerelease_index:", docs_index) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..267ca5e --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,797 @@ +import json +import os +import shutil +import tempfile +import unittest +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +from unittest import mock + +from scripts.release import release + + +def parse_args(*argv): + return release.build_parser().parse_args(list(argv)) + + +GH_NOT_FOUND = ( + 'printf \'{"message":"Not Found","documentation_url":"https://docs.github.com","status":"404"}\'\n' + "echo 'gh: Not Found (HTTP 404)' >&2\n" + "exit 1\n" +) + +GIT_TAGS_ABSENT = ( + 'if [[ "$1" == "rev-parse" ]]; then exit 1; fi\n' + 'if [[ "$1" == "ls-remote" ]]; then exit 2; fi\n' + "exit 0\n" +) + +# Repo lookups succeed (the repo is accessible); everything else is a 404. +GH_REPO_OK_RELEASES_ABSENT = ( + 'if [[ "$2" == "repos/roc-lang/example" ]]; then printf \'{}\'; exit 0; fi\n' + GH_NOT_FOUND +) + + +class ReleaseHelpersTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + + def tearDown(self): + shutil.rmtree(self.tmp) + + def write(self, path, text=""): + target = self.tmp / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + return target + + @contextmanager + def fake_commands(self, env=None, **scripts): + bin_dir = self.tmp / "fake-bin" + bin_dir.mkdir(exist_ok=True) + for name, body in scripts.items(): + path = bin_dir / name + path.write_text("#!/usr/bin/env bash\n" + body, encoding="utf-8") + path.chmod(0o755) + overrides = {"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"} + overrides.update(env or {}) + with mock.patch.dict(os.environ, overrides): + yield + + def prepare_args(self, **overrides): + values = { + "bundle_glob": "dist/*.tar.zst", + "bundle_manifest_path": "", + "test_os_json": '["ubuntu-latest"]', + "workspace": str(self.tmp), + "bundle_dir": str(self.tmp / "out"), + "matrix_file": str(self.tmp / "matrix.json"), + "release_list_file": str(self.tmp / "release.json"), + "github_output": "", + } + values.update(overrides) + return parse_args( + "prepare-bundles", + "--bundle-glob", values["bundle_glob"], + "--bundle-manifest-path", values["bundle_manifest_path"], + "--test-os-json", values["test_os_json"], + "--workspace", values["workspace"], + "--bundle-dir", values["bundle_dir"], + "--matrix-file", values["matrix_file"], + "--release-list-file", values["release_list_file"], + "--github-output", values["github_output"], + ) + + # --- version parsing --- + + def test_validate_release_version_accepts_strict_semver(self): + self.assertEqual(release.validate_release_version("1.2.3"), "1.2.3") + self.assertEqual(release.validate_release_version("0.0.1"), "0.0.1") + self.assertEqual(release.validate_release_version("1.2.3-rc1"), "1.2.3-rc1") + self.assertEqual(release.validate_release_version("1.2.3-alpha.1"), "1.2.3-alpha.1") + self.assertEqual(release.parse_release_version("1.2.3-rc1").base, "1.2.3") + self.assertTrue(release.parse_release_version("1.2.3-rc1").is_prerelease) + self.assertFalse(release.parse_release_version("1.2.3").is_prerelease) + + def test_validate_release_version_rejects_invalid_versions(self): + for version in [ + "", + "v1.2.3", + "1.2", + "01.2.3", + "0.0.0", + "1.2.3-", + "1.2.3-01", + "1.2.3+build.1", + "1.2.3\n", + " 1.2.3", + ]: + with self.subTest(version=version): + with self.assertRaises(release.ReleaseError): + release.validate_release_version(version) + + # --- prepare-bundles --- + + def test_prepare_bundles_from_glob(self): + bundle = self.write("dist/pkg.tar.zst", "bundle") + output_file = self.tmp / "github-output.txt" + args = self.prepare_args( + test_os_json='["ubuntu-latest","macos-latest"]', + github_output=str(output_file), + ) + self.assertEqual(release.cmd_prepare_bundles(args), 0) + + matrix = json.loads((self.tmp / "matrix.json").read_text(encoding="utf-8")) + self.assertEqual( + matrix, + [ + {"artifact_file": bundle.name, "bundle_name": "default", "os": "ubuntu-latest"}, + {"artifact_file": bundle.name, "bundle_name": "default", "os": "macos-latest"}, + ], + ) + self.assertTrue((self.tmp / "out" / bundle.name).is_file()) + self.assertIn("test_matrix=", output_file.read_text(encoding="utf-8")) + + def test_prepare_bundles_requires_glob_match(self): + with self.assertRaisesRegex(release.ReleaseError, "matched no files"): + release.cmd_prepare_bundles(self.prepare_args()) + + def test_prepare_bundles_rejects_output_dir_outside_workspace(self): + self.write("dist/pkg.tar.zst", "bundle") + args = self.prepare_args(bundle_dir=str(self.tmp.parent / "outside-bundles")) + with self.assertRaisesRegex(release.ReleaseError, "inside the workspace"): + release.cmd_prepare_bundles(args) + + def test_prepare_bundles_rejects_workspace_root_output_dir(self): + self.write("dist/pkg.tar.zst", "bundle") + args = self.prepare_args(bundle_dir=str(self.tmp)) + with self.assertRaisesRegex(release.ReleaseError, "workspace root"): + release.cmd_prepare_bundles(args) + + def test_prepare_bundles_rejects_output_dir_containing_source_bundles(self): + self.write("dist/pkg.tar.zst", "bundle") + args = self.prepare_args(bundle_dir=str(self.tmp / "dist")) + with self.assertRaisesRegex(release.ReleaseError, "must not contain"): + release.cmd_prepare_bundles(args) + + def test_prepare_bundles_refuses_to_delete_foreign_bundle_dir(self): + self.write("dist/pkg.tar.zst", "bundle") + self.write(".release/previous-url.txt", "https://example.com/pkg.tar.zst") + args = self.prepare_args(bundle_dir=str(self.tmp / ".release")) + with self.assertRaisesRegex(release.ReleaseError, "not created by prepare-bundles"): + release.cmd_prepare_bundles(args) + self.assertTrue((self.tmp / ".release" / "previous-url.txt").is_file()) + + def test_prepare_bundles_can_rerun_over_own_bundle_dir(self): + self.write("dist/pkg.tar.zst", "bundle") + self.assertEqual(release.cmd_prepare_bundles(self.prepare_args()), 0) + self.assertEqual(release.cmd_prepare_bundles(self.prepare_args()), 0) + self.assertTrue((self.tmp / "out" / "pkg.tar.zst").is_file()) + + def test_prepare_bundles_workspace_with_glob_metacharacters(self): + workspace = self.tmp / "build [linux-2]" + (workspace / "dist").mkdir(parents=True) + (workspace / "dist" / "pkg.tar.zst").write_text("bundle", encoding="utf-8") + args = self.prepare_args( + workspace=str(workspace), + bundle_dir=str(workspace / "out"), + matrix_file=str(workspace / "matrix.json"), + release_list_file=str(workspace / "release.json"), + ) + self.assertEqual(release.cmd_prepare_bundles(args), 0) + self.assertTrue((workspace / "out" / "pkg.tar.zst").is_file()) + + def test_prepare_bundles_rejects_non_bundle_suffix_early(self): + self.write("dist/pkg.txt", "not a bundle") + args = self.prepare_args(bundle_glob="dist/*") + with self.assertRaisesRegex(release.ReleaseError, r"must end with \.tar\.zst"): + release.cmd_prepare_bundles(args) + + def test_prepare_bundles_rejects_hash_in_artifact_filename(self): + self.write("dist/pkg#linux.tar.zst", "bundle") + args = self.prepare_args() + with self.assertRaisesRegex(release.ReleaseError, "must not contain '#'"): + release.cmd_prepare_bundles(args) + + def test_prepare_bundles_rejects_manifest_missing_glob_entry(self): + self.write("dist/default.tar.zst", "default") + self.write("dist/wayland.tar.zst", "wayland") + manifest = self.write( + "dist/release-bundles.json", + json.dumps( + [ + { + "name": "default", + "path": "dist/default.tar.zst", + "test_os": ["ubuntu-latest"], + } + ] + ), + ) + args = self.prepare_args(bundle_manifest_path=str(manifest.relative_to(self.tmp))) + with self.assertRaisesRegex(release.ReleaseError, "without manifest test entries"): + release.cmd_prepare_bundles(args) + + def test_manifest_rejects_duplicate_names_and_empty_tests(self): + self.write("dist/a.tar.zst", "a") + manifest = self.write( + "dist/release-bundles.json", + json.dumps( + [ + {"name": "same", "path": "dist/a.tar.zst", "test_os": ["ubuntu-latest"]}, + {"name": "same", "path": "dist/a.tar.zst", "test_os": []}, + ] + ), + ) + with self.assertRaisesRegex(release.ReleaseError, "duplicate bundle name"): + release.bundles_from_manifest(str(manifest), self.tmp) + + def test_safe_existing_file_rejects_path_traversal(self): + outside = self.tmp.parent / "outside.tar.zst" + outside.write_text("outside", encoding="utf-8") + try: + with self.assertRaisesRegex(release.ReleaseError, "escapes the workspace"): + release.safe_existing_file("../outside.tar.zst", self.tmp) + finally: + outside.unlink(missing_ok=True) + + # --- docs --- + + def test_docs_validation_preserves_historical_versions(self): + self.write("www/1.0.0/index.html", "old") + snapshot = self.tmp / "snapshot.json" + release.cmd_snapshot_docs( + parse_args( + "snapshot-docs", "--docs-root", str(self.tmp / "www"), "--snapshot-file", str(snapshot) + ) + ) + self.write("www/2.0.0/index.html", "new") + release.cmd_validate_docs( + parse_args( + "validate-docs", + "--docs-root", str(self.tmp / "www"), + "--docs-version", "2.0.0", + "--snapshot-file", str(snapshot), + ) + ) + + def test_docs_validation_rejects_removed_historical_versions(self): + self.write("www/1.0.0/index.html", "old") + snapshot = self.tmp / "snapshot.json" + release.cmd_snapshot_docs( + parse_args( + "snapshot-docs", "--docs-root", str(self.tmp / "www"), "--snapshot-file", str(snapshot) + ) + ) + shutil.rmtree(self.tmp / "www" / "1.0.0") + self.write("www/2.0.0/index.html", "new") + with self.assertRaisesRegex(release.ReleaseError, "removed historical"): + release.cmd_validate_docs( + parse_args( + "validate-docs", + "--docs-root", str(self.tmp / "www"), + "--docs-version", "2.0.0", + "--snapshot-file", str(snapshot), + ) + ) + + def test_docs_validation_reports_missing_snapshot(self): + self.write("www/2.0.0/index.html", "new") + with self.assertRaisesRegex(release.ReleaseError, "docs snapshot file is missing"): + release.cmd_validate_docs( + parse_args( + "validate-docs", + "--docs-root", str(self.tmp / "www"), + "--docs-version", "2.0.0", + "--snapshot-file", str(self.tmp / "missing-snapshot.json"), + ) + ) + + def test_write_docs_index(self): + release.cmd_write_docs_index( + parse_args( + "write-docs-index", + "--docs-root", str(self.tmp / "www"), + "--docs-version", "1.2.3", + "--repo-name", "roc-ansi", + ) + ) + index = (self.tmp / "www" / "index.html").read_text(encoding="utf-8") + self.assertIn("/roc-ansi/1.2.3/", index) + + def test_write_docs_index_skips_prerelease_by_default(self): + output_file = self.tmp / "github-output.txt" + with redirect_stdout(StringIO()): + release.cmd_write_docs_index( + parse_args( + "write-docs-index", + "--docs-root", str(self.tmp / "www"), + "--docs-version", "1.2.3-rc1", + "--repo-name", "roc-ansi", + "--github-output", str(output_file), + ) + ) + self.assertFalse((self.tmp / "www" / "index.html").exists()) + # The output must not claim a file that was never written. + self.assertFalse(output_file.exists()) + + def test_write_docs_index_can_update_prerelease_when_allowed(self): + output_file = self.tmp / "github-output.txt" + release.cmd_write_docs_index( + parse_args( + "write-docs-index", + "--docs-root", str(self.tmp / "www"), + "--docs-version", "1.2.3-rc1", + "--repo-name", "roc-ansi", + "--update-prerelease-index", "true", + "--github-output", str(output_file), + ) + ) + index = (self.tmp / "www" / "index.html").read_text(encoding="utf-8") + self.assertIn("/roc-ansi/1.2.3-rc1/", index) + self.assertIn("index_file=", output_file.read_text(encoding="utf-8")) + + # --- run-bump-check --- + + def bump_args(self, mode="require", version="1.2.3", previous_url="", dry_run="false", output=None): + return parse_args( + "run-bump-check", + "--mode", mode, + "--version", version, + "--entrypoint", "main.roc", + "--previous-url", previous_url, + "--output-file", str(output or (self.tmp / "bump.txt")), + "--dry-run", dry_run, + ) + + def test_run_bump_check_warn_does_not_fail(self): + output = self.tmp / "bump.txt" + with self.fake_commands(roc="echo bump failed >&2\nexit 1\n"): + with redirect_stderr(StringIO()): + code = release.cmd_run_bump_check( + self.bump_args( + mode="warn", + previous_url="https://example.com/1.2.2/pkg.tar.zst", + output=output, + ) + ) + self.assertEqual(code, 0) + self.assertIn("bump failed", output.read_text(encoding="utf-8")) + + def test_run_bump_check_require_fails_on_bump_error(self): + with self.fake_commands(roc="echo bump failed >&2\nexit 1\n"): + with redirect_stderr(StringIO()): + with self.assertRaisesRegex(release.ReleaseError, "roc bump failed"): + release.cmd_run_bump_check( + self.bump_args(previous_url="https://example.com/pkg.tar.zst") + ) + + def test_run_bump_check_dry_run_does_not_call_roc(self): + output = self.tmp / "bump.txt" + with self.fake_commands(roc="echo should not run >&2\nexit 1\n"): + code = release.cmd_run_bump_check( + self.bump_args( + version="999.999.999", + previous_url="https://example.com/pkg.tar.zst", + dry_run="true", + output=output, + ) + ) + self.assertEqual(code, 0) + self.assertIn("dry-run release 999.999.999", output.read_text(encoding="utf-8")) + + def test_run_bump_check_uses_base_version_for_prerelease(self): + log = self.tmp / "roc.log" + with self.fake_commands( + env={"ROC_LOG": str(log)}, + roc='printf \'%s\\n\' "$*" > "$ROC_LOG"\n', + ): + code = release.cmd_run_bump_check( + self.bump_args(version="1.2.3-rc1", previous_url="https://example.com/pkg.tar.zst") + ) + self.assertEqual(code, 0) + self.assertIn("--expect 1.2.3 main.roc", log.read_text(encoding="utf-8")) + self.assertNotIn("1.2.3-rc1", log.read_text(encoding="utf-8")) + + def test_run_bump_check_require_fails_without_previous_url(self): + with self.assertRaisesRegex(release.ReleaseError, "no previous release bundle URL"): + release.cmd_run_bump_check(self.bump_args(previous_url="")) + + def test_run_bump_check_warn_skips_without_previous_url(self): + output = self.tmp / "bump.txt" + code = release.cmd_run_bump_check(self.bump_args(mode="warn", previous_url="", output=output)) + self.assertEqual(code, 0) + self.assertIn("No previous release bundle found;", output.read_text(encoding="utf-8")) + + def test_run_bump_check_version_falls_back_to_env(self): + output = self.tmp / "bump.txt" + with mock.patch.dict(os.environ, {"RELEASE_VERSION": "3.2.1"}): + code = release.cmd_run_bump_check(self.bump_args(version="", dry_run="true", output=output)) + self.assertEqual(code, 0) + self.assertIn("dry-run release 3.2.1", output.read_text(encoding="utf-8")) + + # --- resolve-previous-url --- + + def resolve_args(self, output=None, github_output=""): + return parse_args( + "resolve-previous-url", + "--provided-url", "", + "--repo", "roc-lang/example", + "--output-file", str(output or (self.tmp / "previous-url.txt")), + "--github-output", github_output, + ) + + def test_resolve_previous_url_uses_single_latest_asset(self): + output = self.tmp / "previous-url.txt" + github_output = self.tmp / "github-output.txt" + gh = ( + "printf '{\"assets\":[{\"name\":\"pkg.tar.zst\"," + "\"browser_download_url\":\"https://example.com/pkg.tar.zst\"}]}'\n" + ) + with self.fake_commands(gh=gh): + release.cmd_resolve_previous_url( + self.resolve_args(output=output, github_output=str(github_output)) + ) + self.assertEqual(output.read_text(encoding="utf-8"), "https://example.com/pkg.tar.zst\n") + self.assertIn( + "previous_url=https://example.com/pkg.tar.zst", + github_output.read_text(encoding="utf-8"), + ) + + def test_resolve_previous_url_rejects_multiple_assets(self): + gh = ( + "printf '{\"assets\":[" + "{\"name\":\"a.tar.zst\",\"browser_download_url\":\"https://example.com/a.tar.zst\"}," + "{\"name\":\"b.tar.zst\",\"browser_download_url\":\"https://example.com/b.tar.zst\"}" + "]}'\n" + ) + with self.fake_commands(gh=gh): + with self.assertRaisesRegex(release.ReleaseError, "multiple"): + release.cmd_resolve_previous_url(self.resolve_args()) + + def test_resolve_previous_url_rejects_newline_provided_url(self): + with self.assertRaisesRegex(release.ReleaseError, "must not contain newlines"): + release.cmd_resolve_previous_url( + parse_args( + "resolve-previous-url", + "--provided-url", "https://example.com/pkg.tar.zst\nextra=value", + "--repo", "roc-lang/example", + "--output-file", str(self.tmp / "previous-url.txt"), + ) + ) + + def test_resolve_previous_url_treats_missing_release_as_empty(self): + output = self.tmp / "previous-url.txt" + with self.fake_commands(gh=GH_REPO_OK_RELEASES_ABSENT): + self.assertEqual(release.cmd_resolve_previous_url(self.resolve_args(output=output)), 0) + self.assertEqual(output.read_text(encoding="utf-8"), "\n") + + def test_resolve_previous_url_rejects_inaccessible_repo(self): + # GitHub reports both missing repos and forbidden repos as 404; that + # must not be mistaken for "no previous release exists". + with self.fake_commands(gh=GH_NOT_FOUND): + with self.assertRaisesRegex(release.ReleaseError, "cannot access repository"): + release.cmd_resolve_previous_url(self.resolve_args()) + + # --- check-availability --- + + def test_check_availability_passes_when_tag_and_release_are_absent(self): + with self.fake_commands(git=GIT_TAGS_ABSENT, gh=GH_REPO_OK_RELEASES_ABSENT): + self.assertEqual( + release.cmd_check_availability( + parse_args("check-availability", "1.2.3", "--repo", "roc-lang/example") + ), + 0, + ) + + def test_check_availability_rejects_existing_release(self): + with self.fake_commands(git=GIT_TAGS_ABSENT, gh="printf '{}'\n"): + with self.assertRaisesRegex(release.ReleaseError, "already exists"): + release.cmd_check_availability( + parse_args("check-availability", "1.2.3", "--repo", "roc-lang/example") + ) + + def test_check_availability_rejects_inaccessible_repo(self): + with self.fake_commands(git=GIT_TAGS_ABSENT, gh=GH_NOT_FOUND): + with self.assertRaisesRegex(release.ReleaseError, "cannot access repository"): + release.cmd_check_availability( + parse_args("check-availability", "1.2.3", "--repo", "roc-lang/example") + ) + + # --- validate-release --- + + def test_validate_release_dry_run_writes_outputs_and_skips_availability(self): + output_file = self.tmp / "github-output.txt" + env_file = self.tmp / "github-env.txt" + with self.fake_commands(git="echo should not run >&2\nexit 1\n", gh="echo should not run >&2\nexit 1\n"): + with redirect_stdout(StringIO()) as stdout: + code = release.cmd_validate_release( + parse_args( + "validate-release", + "--dry-run", "true", + "--github-output", str(output_file), + "--github-env", str(env_file), + ) + ) + self.assertEqual(code, 0) + self.assertIn("synthetic dry-run version", stdout.getvalue()) + outputs = output_file.read_text(encoding="utf-8") + self.assertIn("release_version=999.999.999", outputs) + self.assertIn("is_dry_run=true", outputs) + self.assertIn("is_prerelease=false", outputs) + env_values = env_file.read_text(encoding="utf-8") + self.assertIn("RELEASE_VERSION=999.999.999", env_values) + self.assertIn("DOCS_VERSION=999.999.999", env_values) + + def test_validate_release_checks_availability_for_real_version_in_dry_run(self): + # A real release_version must be availability-checked even in dry-run + # mode, so duplicate versions fail during PR validation. + with self.fake_commands(git=GIT_TAGS_ABSENT, gh="printf '{}'\n"): + with self.assertRaisesRegex(release.ReleaseError, "already exists"): + release.cmd_validate_release( + parse_args( + "validate-release", + "--release-version", "1.2.3", + "--dry-run", "true", + "--repo", "roc-lang/example", + ) + ) + + def test_validate_release_reports_prerelease_versions(self): + output_file = self.tmp / "github-output.txt" + with redirect_stdout(StringIO()): + release.cmd_validate_release( + parse_args( + "validate-release", + "--release-version", "1.2.3-rc1", + "--check-availability", "false", + "--github-output", str(output_file), + ) + ) + outputs = output_file.read_text(encoding="utf-8") + self.assertIn("release_version=1.2.3-rc1", outputs) + self.assertIn("release_base_version=1.2.3", outputs) + self.assertIn("is_prerelease=true", outputs) + + def test_validate_release_requires_version_without_dry_run(self): + with self.assertRaisesRegex(release.ReleaseError, "release_version is required"): + release.cmd_validate_release(parse_args("validate-release")) + + # --- make-release-notes --- + + def notes_args(self, version="1.2.3", bump_output=None, output=None, docs_url=""): + return parse_args( + "make-release-notes", + "--repo", "roc-lang/example", + "--version", version, + "--target", "abc123", + "--bump-output", str(bump_output or (self.tmp / "missing-bump.txt")), + "--output-file", str(output or (self.tmp / "notes.md")), + "--docs-url", docs_url, + ) + + def test_make_release_notes_appends_bump_output(self): + bump = self.write("bump.txt", "Added Foo\n") + output = self.tmp / "notes.md" + with self.fake_commands(gh="printf '{\"body\":\"Generated notes\"}'\n"): + release.cmd_make_release_notes(self.notes_args(bump_output=bump, output=output)) + notes = output.read_text(encoding="utf-8") + self.assertIn("Generated notes", notes) + self.assertIn("Added Foo", notes) + + def test_make_release_notes_omits_skipped_bump_output(self): + bump = self.write("bump.txt", "roc bump check skipped because bump_check is off.\n") + output = self.tmp / "notes.md" + with self.fake_commands(gh="printf '{\"body\":\"Generated notes\"}'\n"): + release.cmd_make_release_notes(self.notes_args(bump_output=bump, output=output)) + notes = output.read_text(encoding="utf-8") + self.assertIn("Generated notes", notes) + self.assertNotIn("Roc API Changes", notes) + + def test_make_release_notes_appends_docs_url(self): + output = self.tmp / "notes.md" + with self.fake_commands(gh="printf '{\"body\":\"Generated notes\"}'\n"): + release.cmd_make_release_notes( + self.notes_args( + version="1.2.3-rc1", + output=output, + docs_url="https://roc-lang.github.io/example/1.2.3-rc1/", + ) + ) + notes = output.read_text(encoding="utf-8") + self.assertIn("Generated notes", notes) + self.assertIn("View docs for 1.2.3-rc1", notes) + self.assertIn("https://roc-lang.github.io/example/1.2.3-rc1/", notes) + + def test_make_release_notes_extends_fence_for_embedded_backticks(self): + bump = self.write("bump.txt", "Changed\n```\nexample fence\n```\n") + output = self.tmp / "notes.md" + with self.fake_commands(gh="printf '{\"body\":\"Generated notes\"}'\n"): + release.cmd_make_release_notes(self.notes_args(bump_output=bump, output=output)) + notes = output.read_text(encoding="utf-8") + self.assertIn("````text", notes) + self.assertIn("example fence", notes) + + def test_make_release_notes_requires_repo(self): + args = parse_args( + "make-release-notes", + "--repo", "", + "--version", "1.2.3", + "--target", "abc123", + "--bump-output", str(self.tmp / "missing-bump.txt"), + "--output-file", str(self.tmp / "notes.md"), + ) + with mock.patch.dict(os.environ, {"GITHUB_REPOSITORY": ""}): + with self.assertRaisesRegex(release.ReleaseError, "GITHUB_REPOSITORY or --repo"): + release.cmd_make_release_notes(args) + + # --- append-github-output --- + + def test_append_github_output_rejects_newline_values(self): + output = self.tmp / "github-output.txt" + with self.assertRaisesRegex(release.ReleaseError, "must not contain newlines"): + release.cmd_append_github_output( + parse_args( + "append-github-output", + "--github-output", str(output), + "--name", "previous_url", + "--value", "https://example.com/pkg.tar.zst\nextra=value", + ) + ) + + def test_append_github_output_rejects_invalid_names(self): + output = self.tmp / "github-output.txt" + for name in ["bad.name", "bad\nname", "name\n"]: + with self.subTest(name=name): + with self.assertRaisesRegex(release.ReleaseError, "invalid GitHub output name"): + release.append_github_output(str(output), name, "ok") + + # --- publish-release --- + + def publish_args(self, version="1.2.3", notes=None, bundle_dir=None, release_list=None, check_availability="true"): + return parse_args( + "publish-release", + "--version", version, + "--repo", "roc-lang/example", + "--target", "abc123", + "--notes-file", str(notes), + "--bundle-dir", str(bundle_dir), + "--release-list-file", str(release_list), + "--check-availability", check_availability, + ) + + def test_publish_release_rechecks_availability_and_creates_release(self): + log = self.tmp / "commands.log" + git = ( + 'case "$1" in\n' + " rev-parse) exit 1 ;;\n" + " ls-remote) exit 2 ;;\n" + "esac\n" + 'echo "git:$*" >> "$LOG_PATH"\n' + ) + gh = ( + 'if [[ "$1" == "api" && "$2" == "repos/roc-lang/example" ]]; then printf \'{}\'; exit 0; fi\n' + 'if [[ "$1" == "api" ]]; then\n' + GH_NOT_FOUND + "fi\n" + 'echo "gh:$*" >> "$LOG_PATH"\n' + ) + notes = self.write("release-notes.md", "Generated notes\n") + bundle_dir = self.tmp / "bundles" + bundle_dir.mkdir() + self.write("bundles/pkg.tar.zst", "bundle") + self.write("bundles/debug.txt", "not for release") + release_list = self.write( + "release-bundles.json", + json.dumps( + [ + { + "name": "default", + "artifact_file": "pkg.tar.zst", + "source_path": "dist/pkg.tar.zst", + } + ] + ), + ) + with self.fake_commands(env={"LOG_PATH": str(log)}, git=git, gh=gh): + self.assertEqual( + release.cmd_publish_release( + self.publish_args(notes=notes, bundle_dir=bundle_dir, release_list=release_list) + ), + 0, + ) + commands = log.read_text(encoding="utf-8") + self.assertIn("git:tag 1.2.3 abc123", commands) + self.assertIn("git:push origin refs/tags/1.2.3", commands) + self.assertIn("gh:release create 1.2.3", commands) + self.assertIn(str(bundle_dir / "pkg.tar.zst"), commands) + self.assertNotIn("debug.txt", commands) + self.assertIn("--notes-file", commands) + + def test_publish_release_marks_prerelease(self): + log = self.tmp / "commands.log" + notes = self.write("release-notes.md", "Generated notes\n") + bundle_dir = self.tmp / "bundles" + bundle_dir.mkdir() + self.write("bundles/pkg.tar.zst", "bundle") + release_list = self.write( + "release-bundles.json", + json.dumps([{"name": "default", "artifact_file": "pkg.tar.zst"}]), + ) + with self.fake_commands( + env={"LOG_PATH": str(log)}, + git='echo "git:$*" >> "$LOG_PATH"\n', + gh='echo "gh:$*" >> "$LOG_PATH"\n', + ): + release.cmd_publish_release( + self.publish_args( + version="1.2.3-rc1", + notes=notes, + bundle_dir=bundle_dir, + release_list=release_list, + check_availability="false", + ) + ) + commands = log.read_text(encoding="utf-8") + self.assertIn("git:tag 1.2.3-rc1 abc123", commands) + self.assertIn("gh:release create 1.2.3-rc1", commands) + self.assertIn("--prerelease", commands) + + def test_publish_release_requires_nonempty_notes(self): + bundle_dir = self.tmp / "bundles" + bundle_dir.mkdir() + self.write("bundles/pkg.tar.zst", "bundle") + notes = self.write("release-notes.md", "") + release_list = self.write( + "release-bundles.json", + json.dumps([{"name": "default", "artifact_file": "pkg.tar.zst"}]), + ) + with self.assertRaisesRegex(release.ReleaseError, "release notes file is empty"): + release.cmd_publish_release( + self.publish_args( + notes=notes, + bundle_dir=bundle_dir, + release_list=release_list, + check_availability="false", + ) + ) + + def test_publish_release_requires_assets(self): + notes = self.write("release-notes.md", "Generated notes\n") + bundle_dir = self.tmp / "bundles" + bundle_dir.mkdir() + release_list = self.write( + "release-bundles.json", + json.dumps([{"name": "default", "artifact_file": "pkg.tar.zst"}]), + ) + with self.assertRaisesRegex(release.ReleaseError, "release asset is missing"): + release.cmd_publish_release( + self.publish_args( + notes=notes, + bundle_dir=bundle_dir, + release_list=release_list, + check_availability="false", + ) + ) + + def test_publish_release_rejects_unexpected_asset_extension(self): + notes = self.write("release-notes.md", "Generated notes\n") + bundle_dir = self.tmp / "bundles" + bundle_dir.mkdir() + self.write("bundles/pkg.txt", "not a bundle") + release_list = self.write( + "release-bundles.json", + json.dumps([{"name": "default", "artifact_file": "pkg.txt"}]), + ) + with self.assertRaisesRegex(release.ReleaseError, r"must end with \.tar\.zst"): + release.cmd_publish_release( + self.publish_args( + notes=notes, + bundle_dir=bundle_dir, + release_list=release_list, + check_availability="false", + ) + ) + + +if __name__ == "__main__": + unittest.main()