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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: npm
Expand Down
104 changes: 79 additions & 25 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,63 +1,117 @@
name: Release

on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
bump:
description: Version bump type
required: true
type: choice
default: minor
options:
- patch
- minor
- major

permissions:
contents: read
contents: write

concurrency:
group: release

jobs:
validate:
release:
if: github.repository == 'getsentry/action-filter-paths'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run check
- name: Validate version tag
env:
TAG: ${{ github.ref_name }}

- name: Validate release
run: |
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "invalid release tag: $TAG" >&2
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
echo "releases must be dispatched from main, not $GITHUB_REF" >&2
exit 1
fi
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "release tag must point to a commit on main" >&2

git fetch origin main --tags
if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
if [[ "$GITHUB_RUN_ATTEMPT" == "1" ]] || ! git tag --points-at "$GITHUB_SHA" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "new releases must use the latest commit on main" >&2
exit 1
fi
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "the retried release commit is no longer on main" >&2
exit 1
fi
echo "Retrying a partially published release after main advanced"
fi

- name: Calculate version
id: release
env:
BUMP: ${{ inputs.bump }}
run: |
while IFS= read -r tag; do
if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Reusing $tag already attached to this commit"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
exit 0
fi
done < <(git tag --points-at "$GITHUB_SHA" --sort=-version:refname)

current="0.0.0"
while IFS= read -r tag; do
if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
current="${tag#v}"
break
fi
done < <(git tag --list 'v*' --sort=-version:refname)

IFS=. read -r major minor patch <<< "$current"
case "$BUMP" in
major) version="$((major + 1)).0.0" ;;
minor) version="$major.$((minor + 1)).0" ;;
patch) version="$major.$minor.$((patch + 1))" ;;
esac

tag="v$version"
if git rev-parse --verify --quiet "refs/tags/$tag" >/dev/null; then
echo "$tag already exists on another commit" >&2
exit 1
fi
echo "Bumping $current to $version"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
Comment thread
cursor[bot] marked this conversation as resolved.

- run: npm ci
- run: npm run check

release:
needs: validate
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Publish GitHub release
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
if ! gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes --title "$GITHUB_REF_NAME"
if gh release view "$TAG" >/dev/null 2>&1; then
echo "$TAG is already published"
elif git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null; then
gh release create "$TAG" --verify-tag --fail-on-no-commits --generate-notes --title "$TAG"
else
gh release create "$TAG" --target "$GITHUB_SHA" --fail-on-no-commits --generate-notes --title "$TAG"
fi

- name: Advance major tag
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
TAG: ${{ steps.release.outputs.tag }}
run: |
major="${TAG%%.*}"
Comment thread
sentry[bot] marked this conversation as resolved.
latest="$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name' | grep -E "^${major}\.[0-9]+\.[0-9]+$" | sort -V | tail -1)"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,4 @@ Commit `dist/` whenever runtime code or dependencies change.

## Releasing

From a clean, up-to-date `main` checkout, run `scripts/release.sh X.Y.Z`. The script verifies tests and the committed bundle before pushing `vX.Y.Z`. The release workflow verifies it again, publishes a GitHub Release, and advances the `vX` convenience tag only when this is the latest stable semantic version in that major line. Consumers with strict supply-chain requirements should always pin the immutable full commit SHA.
Run the **Release** workflow from the GitHub Actions tab on `main` and choose a patch, minor, or major version bump. The workflow calculates the next version from the latest stable release tag, verifies tests and the committed bundle, publishes the corresponding GitHub Release, and advances the `vX` convenience tag. Each release requires new commits; dispatching again from an already released commit resumes that release instead of creating a duplicate. With no existing release, the default minor bump publishes `v0.1.0` and creates `v0`. Consumers with strict supply-chain requirements should always pin the immutable full commit SHA.
6 changes: 3 additions & 3 deletions dist/index.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"yaml": "2.9.0"
},
"devDependencies": {
"@vercel/ncc": "0.38.3"
"@vercel/ncc": "0.44.1"
},
"engines": {
"node": ">=24"
Expand Down
36 changes: 0 additions & 36 deletions scripts/release.sh

This file was deleted.

140 changes: 140 additions & 0 deletions test/release-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,109 @@ import YAML from "yaml";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const workflow = YAML.parse(fs.readFileSync(path.join(root, ".github/workflows/release.yml"), "utf8"));
const validateRelease = workflow.jobs.release.steps.find((step) => step.name === "Validate release").run;
const calculateVersion = workflow.jobs.release.steps.find((step) => step.name === "Calculate version").run;
const publishRelease = workflow.jobs.release.steps.find((step) => step.name === "Publish GitHub release").run;
const advanceMajor = workflow.jobs.release.steps.find((step) => step.name === "Advance major tag").run;

function calculate(bump, tags = [], currentTags = []) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "release-version-"));
const output = path.join(directory, "output");
execFileSync("git", ["init", "--initial-branch=main", directory]);
execFileSync("git", ["config", "user.name", "Test"], { cwd: directory });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: directory });
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: directory });
execFileSync("git", ["config", "tag.gpgsign", "false"], { cwd: directory });
fs.writeFileSync(path.join(directory, "fixture"), "previous");
execFileSync("git", ["add", "fixture"], { cwd: directory });
execFileSync("git", ["commit", "-m", "previous"], { cwd: directory });
for (const tag of tags) execFileSync("git", ["tag", tag], { cwd: directory });
fs.writeFileSync(path.join(directory, "fixture"), "current");
execFileSync("git", ["commit", "-am", "current"], { cwd: directory });
for (const tag of currentTags) execFileSync("git", ["tag", tag], { cwd: directory });
const sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: directory, encoding: "utf8" }).trim();
execFileSync("bash", ["-euo", "pipefail", "-c", calculateVersion], {
cwd: directory,
env: {
...process.env,
BUMP: bump,
GITHUB_OUTPUT: output,
GITHUB_SHA: sha
}
});
return fs.readFileSync(output, "utf8").trim();
}

function validateAfterMainAdvances(attempt, { tag = true } = {}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "release-validation-"));
const remote = path.join(directory, "remote.git");
const checkout = path.join(directory, "checkout");
execFileSync("git", ["init", "--bare", remote]);
execFileSync("git", ["init", "--initial-branch=main", checkout]);
execFileSync("git", ["config", "user.name", "Test"], { cwd: checkout });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: checkout });
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: checkout });
execFileSync("git", ["config", "tag.gpgsign", "false"], { cwd: checkout });
execFileSync("git", ["remote", "add", "origin", remote], { cwd: checkout });
fs.writeFileSync(path.join(checkout, "fixture"), "release");
execFileSync("git", ["add", "fixture"], { cwd: checkout });
execFileSync("git", ["commit", "-m", "release"], { cwd: checkout });
const releaseSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: checkout, encoding: "utf8" }).trim();
if (tag) execFileSync("git", ["tag", "v0.1.0"], { cwd: checkout });
execFileSync("git", ["push", "--set-upstream", "origin", "main", "--tags"], { cwd: checkout });
fs.writeFileSync(path.join(checkout, "fixture"), "later");
execFileSync("git", ["commit", "-am", "later"], { cwd: checkout });
execFileSync("git", ["push", "origin", "main"], { cwd: checkout });
execFileSync("git", ["checkout", "--detach", releaseSha], { cwd: checkout });
return () =>
execFileSync("bash", ["-euo", "pipefail", "-c", validateRelease], {
cwd: checkout,
env: {
...process.env,
GITHUB_REF: "refs/heads/main",
GITHUB_RUN_ATTEMPT: attempt,
GITHUB_SHA: releaseSha
}
});
}

function publish(tag, { existingTag = false } = {}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "release-publish-"));
const calls = path.join(directory, "calls");
const gh = path.join(directory, "gh");
execFileSync("git", ["init", "--initial-branch=main", directory]);
execFileSync("git", ["config", "user.name", "Test"], { cwd: directory });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: directory });
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: directory });
execFileSync("git", ["config", "tag.gpgsign", "false"], { cwd: directory });
fs.writeFileSync(path.join(directory, "fixture"), "release");
execFileSync("git", ["add", "fixture"], { cwd: directory });
execFileSync("git", ["commit", "-m", "release"], { cwd: directory });
if (existingTag) execFileSync("git", ["tag", tag], { cwd: directory });
const sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: directory, encoding: "utf8" }).trim();
fs.writeFileSync(
gh,
`#!/usr/bin/env node
const fs = require("node:fs");
const args = process.argv.slice(2);
if (args[0] === "release" && args[1] === "view") process.exit(1);
fs.writeFileSync(process.env.MOCK_CALLS, JSON.stringify(args));
`
);
fs.chmodSync(gh, 0o755);
execFileSync("bash", ["-euo", "pipefail", "-c", publishRelease], {
cwd: directory,
env: {
...process.env,
GITHUB_SHA: sha,
MOCK_CALLS: calls,
PATH: `${directory}:${process.env.PATH}`,
TAG: tag
}
});
return { args: JSON.parse(fs.readFileSync(calls, "utf8")), sha };
}

function runCase(tag, releases, { existingMajor = false } = {}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "release-workflow-"));
const calls = path.join(directory, "calls");
Expand Down Expand Up @@ -68,3 +169,42 @@ test("major tag advances only for the latest release in its major line", () => {
["api", "--method", "PATCH", "repos/getsentry/action-filter-paths/git/refs/tags/v1", "-f", `sha=${sha}`, "-F", "force=true"]
]);
});

test("release versions are bumped from stable tags and can be retried", () => {
assert.equal(calculate("minor"), "tag=v0.1.0");
assert.equal(calculate("patch", ["v0.1.0", "v0"]), "tag=v0.1.1");
assert.equal(calculate("minor", ["v0.1.0", "v0.2.0", "v0"]), "tag=v0.3.0");
assert.equal(calculate("major", ["v0.9.0", "v0"]), "tag=v1.0.0");
assert.equal(calculate("minor", ["v0.1.0"], ["v0.2.0", "v0"]), "tag=v0.2.0");
});

test("release creation requires new commits", () => {
const created = publish("v0.2.0");
assert.deepEqual(created.args, [
"release",
"create",
"v0.2.0",
"--target",
created.sha,
"--fail-on-no-commits",
"--generate-notes",
"--title",
"v0.2.0"
]);
assert.deepEqual(publish("v0.2.0", { existingTag: true }).args, [
"release",
"create",
"v0.2.0",
"--verify-tag",
"--fail-on-no-commits",
"--generate-notes",
"--title",
"v0.2.0"
]);
});

test("only a tagged rerun may finish after main advances", () => {
assert.throws(validateAfterMainAdvances("1"));
assert.throws(validateAfterMainAdvances("2", { tag: false }));
assert.doesNotThrow(validateAfterMainAdvances("2"));
});
Loading