diff --git a/.changeset/release-dispatch-rerun.md b/.changeset/release-dispatch-rerun.md new file mode 100644 index 00000000..a3dbcbf8 --- /dev/null +++ b/.changeset/release-dispatch-rerun.md @@ -0,0 +1,16 @@ +--- +"nostream": patch +"nostream-website": patch +--- + +ci(release): allow the image build to be dispatched for an existing tag + +A published release is immutable, so when the image build fails while publishing +it, the release cannot be re-published to retry: GitHub refuses the round trip +with `immutable cannot be changed when release is immutable`. That left #964's +release with no image and no way to build one. + +`release.yml` now accepts `workflow_dispatch` with a `tag` input, and refuses a +tag that is not a relay release, since the event carries no tag of its own. With +no release flags present the prerelease is read from the version, so a dispatch +of `nostream@3.8.0-rc.1` still leaves `latest` where it is. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27e307be..8d43b158 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,17 @@ name: Release on: release: types: [published] + # A published release is immutable, so a build that fails while publishing it + # cannot be retried by re-publishing the event that triggered it — GitHub + # refuses the draft/publish round trip with `immutable cannot be changed when + # release is immutable`. This is the only path to (re)build an image for a tag + # whose release already exists (#964). + workflow_dispatch: + inputs: + tag: + description: 'Relay release tag to build, e.g. nostream@3.7.0' + required: true + type: string jobs: build-and-push-image: @@ -10,7 +21,7 @@ jobs: # nostream@X.Y.Z, website releases tag nostream-website@X.Y.Z. Only a relay # tag should build the relay container image. The v* branch is kept for the # releases cut before the repo moved to changesets tagging. - if: startsWith(github.event.release.tag_name, 'nostream@') || startsWith(github.event.release.tag_name, 'v') + if: github.event_name == 'workflow_dispatch' || startsWith(github.event.release.tag_name, 'nostream@') || startsWith(github.event.release.tag_name, 'v') name: Build and push container image runs-on: ubuntu-latest env: @@ -22,6 +33,12 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v3 + # Build the code the tag names, not the branch the dispatch was started + # from. A release event already carries the tag as GITHUB_REF, so the + # input only overrides it; checking out `main` while pushing the image as + # `3.7.0` would publish the wrong source under a release version. + with: + ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} - name: Log in to the Container registry uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 with: @@ -46,11 +63,18 @@ jobs: id: tags shell: bash env: - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} PRERELEASE: ${{ github.event.release.prerelease }} run: | set -euo pipefail image="$REGISTRY/$IMAGE_NAME" + # A dispatch has no release flags, so a tag that is not a relay release + # is refused here rather than built: `github.event.release.tag_name` is + # empty, and only this check stands between a typo and a pushed image. + case "$RELEASE_TAG" in + nostream@*|v*) ;; + *) echo "::error::not a relay release tag: '$RELEASE_TAG'"; exit 1 ;; + esac # `nostream@3.7.0` -> `3.7.0`; the pre-changesets form `v3.4.1` -> `3.4.1`. version="${RELEASE_TAG##*@}" version="${version#v}" @@ -61,8 +85,16 @@ jobs: v*) tags="$tags,$image:$RELEASE_TAG" ;; esac # `latest` is the tag both deploy stacks pull, so a prerelease must not - # move it onto a test build. - if [ "$PRERELEASE" != "true" ]; then + # move it onto a test build. A release event states this directly; a + # dispatch does not, so a pre-release version is read from the tag. + prerelease="${PRERELEASE:-}" + if [ -z "$prerelease" ]; then + case "$version" in + *-*) prerelease=true ;; + *) prerelease=false ;; + esac + fi + if [ "$prerelease" != "true" ]; then tags="$tags,$image:latest" fi for tag in ${tags//,/ }; do diff --git a/test/unit/ci/publish-container-image.spec.ts b/test/unit/ci/publish-container-image.spec.ts index 3b1a9095..71289c6b 100644 --- a/test/unit/ci/publish-container-image.spec.ts +++ b/test/unit/ci/publish-container-image.spec.ts @@ -25,26 +25,30 @@ type Workflow = { // and a relay image built from the wrong commit, so it is exercised rather than // string-matched: a typo that still contains the right substring would pass a // `to.include` and skip every relay release. -const evaluateReleaseGate = (tagName: string): boolean => { +const evaluateReleaseGate = (tagName: string, isDispatch = false): boolean => { const expression = (load( readFileSync(join(process.cwd(), '.github', 'workflows', 'release.yml'), 'utf-8'), ) as Workflow).jobs['build-and-push-image'].if ?? '' - // Only the `startsWith(github.event.release.tag_name, '…')` clauses are used - // in this expression; anything else is a workflow change that needs review. + // Only two clause forms are allowed in this expression; anything else is a + // workflow change that needs review rather than a silent pass. const clauses = String(expression) .split('||') .map((clause) => clause.trim()) const startsWith = (value: string, prefix: string) => value.startsWith(prefix) - // Translate the two GitHub expression forms into JavaScript. - const js = clauses - .map((clause) => - clause.replace(/^startsWith\(github\.event\.release\.tag_name, '([^']*)'\)$/, - (_, prefix: string) => `startsWith(tagName, ${JSON.stringify(prefix)})`), - ) - .join(' || ') + const toJs = (clause: string): string => { + const prefix = clause.match(/^startsWith\(github\.event\.release\.tag_name, '([^']*)'\)$/) + if (prefix) return `startsWith(tagName, ${JSON.stringify(prefix[1])})` + const event = clause.match(/^github\.event_name == '([^']*)'$/) + if (event) return `isDispatch === ${event[1] === 'workflow_dispatch'}` + throw new Error(`unsupported clause in the release gate: ${clause}`) + } // eslint-disable-next-line no-new-func - return new Function('tagName', 'startsWith', `return ${js}`)(tagName, startsWith) as boolean + return new Function('tagName', 'startsWith', 'isDispatch', `return ${clauses.map(toJs).join(' || ')}`)( + tagName, + startsWith, + isDispatch, + ) as boolean } // The tags are computed by a `run:` block, not by metadata-action's rules, @@ -59,9 +63,10 @@ const tagStep = (workflow: Workflow): string => { return run } -// Executes the step's own `run:` block with the two inputs it maps from the -// release event, and returns the tags it wrote to the job output. -const imageTags = (workflow: Workflow, releaseTag: string, prerelease: boolean): string[] => { +// Executes the step's own `run:` block with the inputs it maps from the release +// event, and returns the tags it wrote to the job output. `undefined` means a +// `workflow_dispatch`, where the event carries no flags at all. +const imageTags = (workflow: Workflow, releaseTag: string, prerelease?: boolean): string[] => { const dir = mkdtempSync(join(tmpdir(), 'release-tags-')) try { const out = join(dir, 'github-output') @@ -73,7 +78,7 @@ const imageTags = (workflow: Workflow, releaseTag: string, prerelease: boolean): REGISTRY: 'ghcr.io', IMAGE_NAME: 'cameri/nostream', RELEASE_TAG: releaseTag, - PRERELEASE: String(prerelease), + ...(prerelease === undefined ? {} : { PRERELEASE: String(prerelease) }), GITHUB_OUTPUT: out, }, }) @@ -127,6 +132,14 @@ describe('container image publishing workflow', () => { expect(evaluateReleaseGate('nostream-website@1.3.1')).to.equal(false) }) + // A published release is immutable, so the build that publishes its image + // cannot be retried through the same event. The dispatch path is what makes a + // failed build recoverable; the step's own range check keeps it from building + // a non-relay tag, since a dispatch carries no release tag at all. + it('accepts a dispatched run so a failed build can be retried', () => { + expect(evaluateReleaseGate('', true)).to.equal(true) + }) + // The deploy path pulls `:latest`, so the release must actually push it. it('tags a stable release with its version and latest', () => { expect(imageTags(releaseWorkflow, 'nostream@3.7.0', false)).to.deep.equal(['3.7.0', 'latest']) @@ -151,6 +164,36 @@ describe('container image publishing workflow', () => { expect(() => imageTags(releaseWorkflow, 'nostream@3.7.0+build', false)).to.throw(/not a legal Docker tag/) }) + // A published release is immutable, so a failed build has to be re-runnable + // by hand — that is what workflow_dispatch exists for, and it carries no + // release flags for the step to read. + it('builds a dispatched tag with the same tags as the release event', () => { + expect(imageTags(releaseWorkflow, 'nostream@3.7.0')).to.deep.equal(['3.7.0', 'latest']) + }) + + // Without the event's flag the prerelease has to be read from the tag, or a + // dispatch of an rc silently moves `latest` onto a test build. + it('reads a prerelease out of a dispatched tag', () => { + expect(imageTags(releaseWorkflow, 'nostream@3.8.0-rc.1')).to.deep.equal(['3.8.0-rc.1']) + }) + + // `github.event.release.tag_name` is empty on a dispatch, so a mistyped input + // is the only thing that would otherwise reach the build. + it('refuses a dispatched tag that is not a relay release', () => { + expect(() => imageTags(releaseWorkflow, 'nostream-website@1.3.2')).to.throw(/not a relay release tag/) + }) + + // The image must be built from the code its tag names. A dispatch starts on a + // branch, so without this the default ref is checked out and that branch's + // HEAD is published as a release version. The expression itself only runs on + // GitHub, so this asserts its shape: the tag form for a dispatch, and the + // event's own ref when no input is present. + it('checks out the release tag rather than the dispatching branch', () => { + const step = (releaseWorkflow.jobs['build-and-push-image'].steps ?? []).find((entry) => entry.with?.ref) + const expression = (step?.with?.ref ?? '').replace(/^\$\{\{\s*/, '').replace(/\s*\}\}$/, '') + expect(expression, 'a dispatch must build the tag it was given').to.include("format('refs/tags/{0}', inputs.tag)") + expect(expression, 'a release event already carries its tag in github.ref').to.include('|| github.ref') + }) it('builds with the tags the step computed', () => { const build = (releaseWorkflow.jobs['build-and-push-image'].steps ?? []).find((step) => step.with?.tags) expect(build?.with?.tags, 'the build must consume the computed tags').to.equal('${{ steps.tags.outputs.tags }}')