Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/release-dispatch-rerun.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 36 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,25 @@ 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:
# Changesets tags each workspace package on publish: relay releases tag
# 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:
Expand All @@ -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:
Expand All @@ -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 }}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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}"
Expand All @@ -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
Expand Down
73 changes: 58 additions & 15 deletions test/unit/ci/publish-container-image.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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')
Expand All @@ -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,
},
})
Expand Down Expand Up @@ -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'])
Expand All @@ -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 }}')
Expand Down
Loading