template-git-repo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -0
- package/bin/template-git-repo.js +257 -0
- package/docs/ACTIONS.md +195 -0
- package/docs/BADGES.md +221 -0
- package/package.json +56 -0
- package/src/apply.js +164 -0
- package/src/badges.js +353 -0
- package/src/context.js +228 -0
- package/src/index.js +29 -0
- package/src/readme.js +60 -0
- package/template/.github/workflows/auto-merge-and-create-prs.yml +102 -0
- package/template/.github/workflows/auto-merge-claude.yml +54 -0
- package/template/.github/workflows/deploy-test-reports.yml +98 -0
- package/template/.github/workflows/npm-publish.yml +320 -0
- package/template/.github/workflows/tests.yml +100 -0
- package/template/codecov.yml +46 -0
- package/template/scripts/list-test-packages.mjs +137 -0
- package/template/scripts/next-free-version.mjs +169 -0
- package/template/scripts/pin-workspace-deps.mjs +108 -0
- package/template/scripts/restore-pinned-deps.mjs +101 -0
- package/template/scripts/workspace-build-order.mjs +129 -0
- package/template/turbo.json +36 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# A twice-daily sweep that keeps branches from being forgotten: it merges every
|
|
2
|
+
# open PR that is already green and unblocked, then opens a PR for any branch
|
|
3
|
+
# that does not have one.
|
|
4
|
+
#
|
|
5
|
+
# This is the backstop for agent sessions that push a branch and never open a
|
|
6
|
+
# PR, and for PRs whose merge was skipped because a check finished after the
|
|
7
|
+
# per-PR auto-merge workflow had already run.
|
|
8
|
+
|
|
9
|
+
name: Auto-merge PRs and create missing PRs
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
schedule:
|
|
13
|
+
# 00:00 and 12:00 UTC. Scheduled workflows can be delayed under high
|
|
14
|
+
# GitHub Actions load — this is a sweep, not a deadline.
|
|
15
|
+
- cron: "0 */12 * * *"
|
|
16
|
+
workflow_dispatch:
|
|
17
|
+
|
|
18
|
+
permissions:
|
|
19
|
+
contents: write
|
|
20
|
+
pull-requests: write
|
|
21
|
+
|
|
22
|
+
concurrency:
|
|
23
|
+
group: auto-merge-and-create-prs
|
|
24
|
+
cancel-in-progress: false
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
maintain-pull-requests:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
env:
|
|
30
|
+
GH_TOKEN: ${{ secrets.GIT_TOKEN || secrets.GITHUB_TOKEN }}
|
|
31
|
+
GIT_TOKEN: ${{ secrets.GIT_TOKEN || secrets.GITHUB_TOKEN }}
|
|
32
|
+
BASE_BRANCH: ${{ github.event.repository.default_branch }}
|
|
33
|
+
steps:
|
|
34
|
+
- name: Merge eligible existing pull requests
|
|
35
|
+
env:
|
|
36
|
+
REPOSITORY: ${{ github.repository }}
|
|
37
|
+
run: |
|
|
38
|
+
set -euo pipefail
|
|
39
|
+
|
|
40
|
+
# Only PRs that are non-draft, cleanly mergeable, not blocked by a
|
|
41
|
+
# requested change, and whose every check concluded SUCCESS, SKIPPED
|
|
42
|
+
# or NEUTRAL. A pending check means "not yet", not "merge it".
|
|
43
|
+
gh pr list \
|
|
44
|
+
--repo "$REPOSITORY" \
|
|
45
|
+
--state open \
|
|
46
|
+
--json number,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup \
|
|
47
|
+
--jq '.[] | select(.isDraft == false) | select(.mergeStateStatus == "CLEAN") | select((.reviewDecision == "APPROVED") or (.reviewDecision == "")) | select(all(.statusCheckRollup[]?; (.conclusion == "SUCCESS" or .conclusion == "SKIPPED" or .conclusion == "NEUTRAL"))) | .number' \
|
|
48
|
+
| while read -r pr_number; do
|
|
49
|
+
echo "Attempting to merge PR #${pr_number}"
|
|
50
|
+
gh pr merge "$pr_number" \
|
|
51
|
+
--repo "$REPOSITORY" \
|
|
52
|
+
--squash \
|
|
53
|
+
--delete-branch \
|
|
54
|
+
--auto || echo "Could not merge PR #${pr_number}; it may require review, checks, or conflict resolution."
|
|
55
|
+
done
|
|
56
|
+
|
|
57
|
+
- name: Create pull requests for branches without one
|
|
58
|
+
env:
|
|
59
|
+
REPOSITORY: ${{ github.repository }}
|
|
60
|
+
run: |
|
|
61
|
+
set -euo pipefail
|
|
62
|
+
|
|
63
|
+
# No actions/checkout above: this needs every remote branch, and a
|
|
64
|
+
# normal checkout fetches one. Fetching all refs into a bare-ish
|
|
65
|
+
# workspace is cheaper than checkout with fetch-depth: 0.
|
|
66
|
+
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
|
67
|
+
git init -q "$GITHUB_WORKSPACE"
|
|
68
|
+
cd "$GITHUB_WORKSPACE"
|
|
69
|
+
git remote add origin "https://x-access-token:${GIT_TOKEN}@github.com/${REPOSITORY}.git"
|
|
70
|
+
git fetch --quiet origin '+refs/heads/*:refs/remotes/origin/*'
|
|
71
|
+
|
|
72
|
+
base_sha="$(git rev-parse "origin/${BASE_BRANCH}")"
|
|
73
|
+
|
|
74
|
+
git for-each-ref --format='%(refname:strip=3)' refs/remotes/origin \
|
|
75
|
+
| grep -Ev "^(HEAD|${BASE_BRANCH})$" \
|
|
76
|
+
| while read -r branch; do
|
|
77
|
+
open_pr="$(gh pr list --repo "$REPOSITORY" --state open --head "$branch" --json number --jq 'length')"
|
|
78
|
+
# `merged` too: a branch whose PR was merged but which was never
|
|
79
|
+
# deleted would otherwise get a fresh PR opened on every sweep.
|
|
80
|
+
merged_pr="$(gh pr list --repo "$REPOSITORY" --state merged --head "$branch" --json number --jq 'length')"
|
|
81
|
+
branch_sha="$(git rev-parse "origin/${branch}")"
|
|
82
|
+
|
|
83
|
+
if [ "$open_pr" -gt 0 ] || [ "$merged_pr" -gt 0 ]; then
|
|
84
|
+
echo "Skipping ${branch}: a pull request already exists or was previously merged."
|
|
85
|
+
continue
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# An ancestor of the base branch has nothing to contribute; a PR
|
|
89
|
+
# for it would be empty.
|
|
90
|
+
if git merge-base --is-ancestor "$branch_sha" "$base_sha"; then
|
|
91
|
+
echo "Skipping ${branch}: it is already contained in ${BASE_BRANCH}."
|
|
92
|
+
continue
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
echo "Creating PR for ${branch} -> ${BASE_BRANCH}"
|
|
96
|
+
gh pr create \
|
|
97
|
+
--repo "$REPOSITORY" \
|
|
98
|
+
--head "$branch" \
|
|
99
|
+
--base "$BASE_BRANCH" \
|
|
100
|
+
--title "Merge ${branch} into ${BASE_BRANCH}" \
|
|
101
|
+
--body "Automated pull request for branch \`${branch}\`."
|
|
102
|
+
done
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Merges pull requests opened by an agent (or by the maintainers) as soon as
|
|
2
|
+
# their checks pass, so agent-authored work does not sit waiting for a human to
|
|
3
|
+
# click a button.
|
|
4
|
+
#
|
|
5
|
+
# The actor allowlist is the whole safety model: this must never fire for a PR
|
|
6
|
+
# from an arbitrary contributor. Edit the `if:` below to match the accounts you
|
|
7
|
+
# actually want merged automatically.
|
|
8
|
+
|
|
9
|
+
name: Auto-merge agent PRs
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
pull_request:
|
|
13
|
+
types: [opened, synchronize, reopened]
|
|
14
|
+
|
|
15
|
+
permissions:
|
|
16
|
+
contents: write
|
|
17
|
+
pull-requests: write
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
auto-merge:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
if: >
|
|
23
|
+
github.actor == 'claude[bot]' ||
|
|
24
|
+
github.actor == 'anthropic-claude[bot]' ||
|
|
25
|
+
github.actor == '{{OWNER}}'
|
|
26
|
+
steps:
|
|
27
|
+
- name: Enable auto-merge (or merge directly)
|
|
28
|
+
env:
|
|
29
|
+
# A PAT rather than GITHUB_TOKEN: merges pushed with GITHUB_TOKEN do
|
|
30
|
+
# not trigger further workflow runs, so a publish workflow listening
|
|
31
|
+
# on the default branch would never fire for the merge commit.
|
|
32
|
+
GH_TOKEN: ${{ secrets.GIT_TOKEN || secrets.GITHUB_TOKEN }}
|
|
33
|
+
PR: ${{ github.event.pull_request.number }}
|
|
34
|
+
REPO: ${{ github.repository }}
|
|
35
|
+
HEAD_REF: ${{ github.head_ref }}
|
|
36
|
+
run: |
|
|
37
|
+
# Never delete long-lived branches after merging them.
|
|
38
|
+
case "$HEAD_REF" in
|
|
39
|
+
production|prod|staging|develop) MERGE_OPTS="--squash" ;;
|
|
40
|
+
*) MERGE_OPTS="--squash --delete-branch" ;;
|
|
41
|
+
esac
|
|
42
|
+
|
|
43
|
+
# Preferred path: let GitHub merge once required checks pass. Needs
|
|
44
|
+
# "Allow auto-merge" in repo settings plus branch protection with at
|
|
45
|
+
# least one required status check.
|
|
46
|
+
if gh pr merge "$PR" --repo "$REPO" $MERGE_OPTS --auto; then
|
|
47
|
+
echo "Auto-merge enabled for PR #$PR"
|
|
48
|
+
else
|
|
49
|
+
# Fallback for unprotected branches (no required checks): merge now
|
|
50
|
+
# if the PR is mergeable. Without branch protection there is nothing
|
|
51
|
+
# for --auto to wait on, so GitHub rejects it outright.
|
|
52
|
+
echo "Auto-merge unavailable, attempting direct merge"
|
|
53
|
+
gh pr merge "$PR" --repo "$REPO" $MERGE_OPTS
|
|
54
|
+
fi
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Publishes the HTML test report to Cloudflare Workers on every push to the
|
|
2
|
+
# default branch, so the "Test Report" badge in the README links to something
|
|
3
|
+
# current rather than to a workflow log.
|
|
4
|
+
#
|
|
5
|
+
# Requires an `apps/test-reports/` (or equivalent) directory with a
|
|
6
|
+
# wrangler.toml whose `assets.directory` is `dist`, and a root `test:report`
|
|
7
|
+
# script that writes the Vitest HTML reporter output there.
|
|
8
|
+
|
|
9
|
+
name: Test Reports
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
push:
|
|
13
|
+
branches: [{{DEFAULT_BRANCH}}]
|
|
14
|
+
workflow_dispatch:
|
|
15
|
+
|
|
16
|
+
concurrency:
|
|
17
|
+
group: test-reports
|
|
18
|
+
cancel-in-progress: true
|
|
19
|
+
|
|
20
|
+
env:
|
|
21
|
+
REPORT_DIR: apps/test-reports
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
deploy:
|
|
25
|
+
name: Generate & deploy test reports
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
|
|
30
|
+
- uses: oven-sh/setup-bun@v2
|
|
31
|
+
|
|
32
|
+
- name: Install dependencies
|
|
33
|
+
run: {{PACKAGE_MANAGER}} install --ignore-scripts
|
|
34
|
+
|
|
35
|
+
- name: Build workspace dependencies
|
|
36
|
+
run: bunx turbo build || echo "No build tasks"
|
|
37
|
+
|
|
38
|
+
# A red suite is exactly when the report is worth reading, so the run is
|
|
39
|
+
# allowed to fail here and still deploy.
|
|
40
|
+
- name: Run tests with the HTML reporter
|
|
41
|
+
run: {{PACKAGE_MANAGER}} run test:report
|
|
42
|
+
continue-on-error: true
|
|
43
|
+
|
|
44
|
+
# What the step above must not do is leave dist/ missing: wrangler treats
|
|
45
|
+
# an absent `assets.directory` as a hard error, so the run would end on a
|
|
46
|
+
# config error instead of on the test signal. Publish a placeholder rather
|
|
47
|
+
# than lose the deploy.
|
|
48
|
+
- name: Ensure a report exists to deploy
|
|
49
|
+
env:
|
|
50
|
+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
51
|
+
run: |
|
|
52
|
+
if [ -f "$REPORT_DIR/dist/index.html" ]; then
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
echo "::warning::The test runner wrote no HTML report; deploying a placeholder page."
|
|
56
|
+
mkdir -p "$REPORT_DIR/dist"
|
|
57
|
+
cat > "$REPORT_DIR/dist/index.html" <<HTML
|
|
58
|
+
<!doctype html>
|
|
59
|
+
<html lang="en">
|
|
60
|
+
<head>
|
|
61
|
+
<meta charset="utf-8">
|
|
62
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
63
|
+
<title>Test report unavailable</title>
|
|
64
|
+
<style>
|
|
65
|
+
:root { color-scheme: light dark; }
|
|
66
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
67
|
+
font: 16px/1.6 system-ui, sans-serif; padding: 24px; }
|
|
68
|
+
main { max-width: 34rem; }
|
|
69
|
+
h1 { font-size: 1.25rem; margin: 0 0 .75rem; }
|
|
70
|
+
p { margin: 0 0 .75rem; opacity: .85; }
|
|
71
|
+
</style>
|
|
72
|
+
</head>
|
|
73
|
+
<body>
|
|
74
|
+
<main>
|
|
75
|
+
<h1>Test report unavailable</h1>
|
|
76
|
+
<p>The last run of the test suite ended before the HTML reporter could
|
|
77
|
+
write a report, so there is nothing to show here yet.</p>
|
|
78
|
+
<p><a href="$RUN_URL">Open the workflow run</a> for the full output.</p>
|
|
79
|
+
<p>Commit $GITHUB_SHA</p>
|
|
80
|
+
</main>
|
|
81
|
+
</body>
|
|
82
|
+
</html>
|
|
83
|
+
HTML
|
|
84
|
+
|
|
85
|
+
- name: Deploy to Cloudflare Workers
|
|
86
|
+
working-directory: ${{ env.REPORT_DIR }}
|
|
87
|
+
run: npx wrangler deploy
|
|
88
|
+
env:
|
|
89
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
90
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
91
|
+
|
|
92
|
+
- name: Upload report artifact
|
|
93
|
+
if: ${{ !cancelled() }}
|
|
94
|
+
uses: actions/upload-artifact@v4
|
|
95
|
+
with:
|
|
96
|
+
name: test-reports
|
|
97
|
+
path: ${{ env.REPORT_DIR }}/dist/
|
|
98
|
+
if-no-files-found: ignore
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# Publishes every non-private workspace package whose *content* changed, without
|
|
2
|
+
# anyone having to remember to bump a version.
|
|
3
|
+
#
|
|
4
|
+
# The rules this encodes were all learned the hard way:
|
|
5
|
+
#
|
|
6
|
+
# - Compare npm pack integrity against the registry, not version numbers. A
|
|
7
|
+
# version bump is not evidence of a change and an unchanged version is not
|
|
8
|
+
# evidence of no change.
|
|
9
|
+
# - Check the publish credential before building anything. npm answers an
|
|
10
|
+
# unauthorized PUT with E404 ("could not be found"), which reads like a
|
|
11
|
+
# missing package — after every package has already been built, and after
|
|
12
|
+
# each failed attempt has signed a provenance statement into the public
|
|
13
|
+
# sigstore transparency log.
|
|
14
|
+
# - Walk the workspace in dependency order, not alphabetical order. A sibling
|
|
15
|
+
# that has not been built yet has no dist/, and its package.json `types`
|
|
16
|
+
# entries point at files that do not exist.
|
|
17
|
+
# - Never publish the literal "workspace:*" protocol — consumers cannot
|
|
18
|
+
# resolve it.
|
|
19
|
+
# - Treat the registry as the source of truth for which version numbers are
|
|
20
|
+
# spent. It refuses a PUT for any version it has ever seen, including
|
|
21
|
+
# staged and unpublished ones that the `latest` dist-tag cannot show you.
|
|
22
|
+
#
|
|
23
|
+
# Credentials, in order of preference:
|
|
24
|
+
#
|
|
25
|
+
# 1. Trusted publishing (OIDC) — no secret at all. Leave NPM_TOKEN unset and
|
|
26
|
+
# name this repo + workflow as the trusted publisher for each package on
|
|
27
|
+
# npmjs.com. Nothing to rotate, nothing to expire.
|
|
28
|
+
# 2. An NPM_TOKEN secret. Granular access tokens expire after 90 days at
|
|
29
|
+
# most; classic automation tokens no longer work for direct publishing.
|
|
30
|
+
|
|
31
|
+
name: Publish to npm
|
|
32
|
+
|
|
33
|
+
on:
|
|
34
|
+
push:
|
|
35
|
+
branches: [{{DEFAULT_BRANCH}}]
|
|
36
|
+
workflow_dispatch:
|
|
37
|
+
|
|
38
|
+
permissions:
|
|
39
|
+
contents: write
|
|
40
|
+
# Required for trusted publishing (OIDC) and for --provenance.
|
|
41
|
+
id-token: write
|
|
42
|
+
|
|
43
|
+
concurrency:
|
|
44
|
+
group: npm-publish
|
|
45
|
+
cancel-in-progress: false
|
|
46
|
+
|
|
47
|
+
jobs:
|
|
48
|
+
publish:
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
with:
|
|
53
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
54
|
+
fetch-depth: 2
|
|
55
|
+
|
|
56
|
+
- uses: actions/setup-node@v4
|
|
57
|
+
with:
|
|
58
|
+
node-version: 22
|
|
59
|
+
registry-url: "https://registry.npmjs.org"
|
|
60
|
+
|
|
61
|
+
- uses: oven-sh/setup-bun@v2
|
|
62
|
+
|
|
63
|
+
# setup-node writes `always-auth` into the npmrc it generates. npm 10+
|
|
64
|
+
# does not know the key and warns about it on every single invocation,
|
|
65
|
+
# which is hundreds of lines of noise across a run. Authentication comes
|
|
66
|
+
# from the _authToken line, which stays.
|
|
67
|
+
- name: Drop the deprecated always-auth key from the generated npmrc
|
|
68
|
+
run: |
|
|
69
|
+
if [ -n "${NPM_CONFIG_USERCONFIG:-}" ] && [ -f "$NPM_CONFIG_USERCONFIG" ]; then
|
|
70
|
+
sed -i '/^always-auth[[:space:]]*[= ]/d' "$NPM_CONFIG_USERCONFIG"
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
# Some published packages ship a broken `prepare: "husky install"` in
|
|
74
|
+
# their package.json. When npm reconciles bun's linked node_modules store
|
|
75
|
+
# it runs that hook, which dies with "husky: not found" (exit 127) — and
|
|
76
|
+
# npm's --ignore-scripts does NOT suppress it for bun-linked packages.
|
|
77
|
+
# Put a no-op `husky` on PATH so a stray hook becomes harmless.
|
|
78
|
+
- name: Neutralize unused husky hooks
|
|
79
|
+
run: |
|
|
80
|
+
mkdir -p "$RUNNER_TEMP/husky-shim"
|
|
81
|
+
printf '#!/bin/sh\nexit 0\n' > "$RUNNER_TEMP/husky-shim/husky"
|
|
82
|
+
chmod +x "$RUNNER_TEMP/husky-shim/husky"
|
|
83
|
+
echo "$RUNNER_TEMP/husky-shim" >> "$GITHUB_PATH"
|
|
84
|
+
|
|
85
|
+
- name: Verify npm publish credentials
|
|
86
|
+
env:
|
|
87
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
88
|
+
run: |
|
|
89
|
+
set -uo pipefail
|
|
90
|
+
|
|
91
|
+
if [ -n "${NODE_AUTH_TOKEN:-}" ]; then
|
|
92
|
+
if whoami=$(npm whoami 2>&1); then
|
|
93
|
+
echo "🔑 Publishing as npm user '$whoami' (NPM_TOKEN)"
|
|
94
|
+
exit 0
|
|
95
|
+
fi
|
|
96
|
+
echo "$whoami"
|
|
97
|
+
echo "::error title=npm token rejected::The NPM_TOKEN secret no longer authenticates with registry.npmjs.org — it has expired, been revoked, or was replaced. Granular access tokens last 90 days at most. Mint a new token with write access at npmjs.com/settings/<user>/tokens and update the NPM_TOKEN repository secret, or remove the secret entirely and configure trusted publishing."
|
|
98
|
+
exit 1
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
# No secret: trusted publishing. npm >= 11.5.1 trades this job's
|
|
102
|
+
# id-token for a short-lived publish token on its own.
|
|
103
|
+
if ! node -e "
|
|
104
|
+
const need = [11, 5, 1];
|
|
105
|
+
const have = process.argv[1].split('.').map(Number);
|
|
106
|
+
for (let i = 0; i < 3; i++) {
|
|
107
|
+
if ((have[i] || 0) !== need[i]) process.exit((have[i] || 0) > need[i] ? 0 : 1);
|
|
108
|
+
}
|
|
109
|
+
" "$(npm --version)"; then
|
|
110
|
+
echo "⏫ npm $(npm --version) is older than 11.5.1 — upgrading for trusted publishing (OIDC)"
|
|
111
|
+
npm install -g npm@latest
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
# A half-configured empty token in the generated npmrc would make npm
|
|
115
|
+
# try (and fail) to authenticate with it instead of using OIDC.
|
|
116
|
+
if [ -n "${NPM_CONFIG_USERCONFIG:-}" ] && [ -f "$NPM_CONFIG_USERCONFIG" ]; then
|
|
117
|
+
sed -i '/_authToken/d' "$NPM_CONFIG_USERCONFIG"
|
|
118
|
+
fi
|
|
119
|
+
echo "🔑 No NPM_TOKEN secret — publishing via trusted publishing (OIDC) with npm $(npm --version)"
|
|
120
|
+
|
|
121
|
+
# Install the whole workspace once with the repo's package manager, which
|
|
122
|
+
# understands the "workspace:*" protocol and links local packages. Plain
|
|
123
|
+
# `npm install` cannot do this inside a bun/pnpm workspace — it dies on
|
|
124
|
+
# sibling "workspace:*" deps (EUNSUPPORTEDPROTOCOL), which leaves
|
|
125
|
+
# devDependencies uninstalled and builds failing with "vite: not found".
|
|
126
|
+
- name: Install workspace dependencies
|
|
127
|
+
run: {{PACKAGE_MANAGER}} install --ignore-scripts
|
|
128
|
+
|
|
129
|
+
- name: Configure git
|
|
130
|
+
run: |
|
|
131
|
+
git config user.name "github-actions[bot]"
|
|
132
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
133
|
+
|
|
134
|
+
- name: Publish packages whose content changed
|
|
135
|
+
env:
|
|
136
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
137
|
+
run: |
|
|
138
|
+
# GitHub runs `run:` steps with `bash -e -o pipefail`, so not writing
|
|
139
|
+
# `set -e` does not disable errexit. With it on, the first package
|
|
140
|
+
# whose publish fails kills the step and every later package goes
|
|
141
|
+
# unreleased. Turn it off explicitly; per-package outcomes are handled
|
|
142
|
+
# through $rc, and the run still exits non-zero at the end.
|
|
143
|
+
set -uo pipefail
|
|
144
|
+
set +e
|
|
145
|
+
|
|
146
|
+
repo_root="$PWD"
|
|
147
|
+
failed_packages=""
|
|
148
|
+
skipped_builds=""
|
|
149
|
+
unauthorized_packages=""
|
|
150
|
+
unattempted_packages=""
|
|
151
|
+
auth_broken=""
|
|
152
|
+
|
|
153
|
+
build_order=$(node scripts/workspace-build-order.mjs)
|
|
154
|
+
echo "📋 Build order:"
|
|
155
|
+
echo "$build_order" | sed 's/^/ /'
|
|
156
|
+
|
|
157
|
+
for dir in $build_order; do
|
|
158
|
+
pkg="${dir}package.json"
|
|
159
|
+
[ -f "$pkg" ] || { echo "⏭ Skipping $dir (no package.json)"; continue; }
|
|
160
|
+
|
|
161
|
+
is_private=$(node -p "!!require('./$pkg').private")
|
|
162
|
+
name=$(node -p "require('./$pkg').name")
|
|
163
|
+
|
|
164
|
+
if [ "$is_private" = "true" ]; then
|
|
165
|
+
echo "⏭ Skipping $name (private)"
|
|
166
|
+
continue
|
|
167
|
+
fi
|
|
168
|
+
|
|
169
|
+
# One rejected PUT means the credential cannot write, and every
|
|
170
|
+
# remaining package would fail the same way — after another build
|
|
171
|
+
# each, and after signing provenance for a version that will never
|
|
172
|
+
# exist. Stop attempting them; the run still fails at the end.
|
|
173
|
+
if [ -n "$auth_broken" ]; then
|
|
174
|
+
echo "⏭ Skipping $name (npm rejected the publish credential — see above)"
|
|
175
|
+
unattempted_packages="$unattempted_packages $name"
|
|
176
|
+
continue
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
echo "📦 Evaluating $name from $dir"
|
|
180
|
+
|
|
181
|
+
# Each package runs in a subshell with its own `set -e` so a failing
|
|
182
|
+
# step propagates to $rc without killing the outer loop.
|
|
183
|
+
(
|
|
184
|
+
set -e
|
|
185
|
+
cd "$dir"
|
|
186
|
+
|
|
187
|
+
# npm keeps the literal "workspace:*" in the tarball, which
|
|
188
|
+
# consumers cannot resolve. Replace it with a real semver range
|
|
189
|
+
# from the referenced local package; drop deps on local packages
|
|
190
|
+
# that are not published. Only committed back if the version is
|
|
191
|
+
# bumped.
|
|
192
|
+
node "$repo_root/scripts/pin-workspace-deps.mjs"
|
|
193
|
+
|
|
194
|
+
# Build before deciding anything: the tarball comparison below
|
|
195
|
+
# needs real dist output, and a package whose build fails is never
|
|
196
|
+
# published.
|
|
197
|
+
if [ "$(node -p "!!(require('./package.json').scripts||{}).build")" = "true" ]; then
|
|
198
|
+
if ! npm run build; then
|
|
199
|
+
echo "⚠ Build failed for $name — not publishing (won't ship a broken tarball)"
|
|
200
|
+
exit 42
|
|
201
|
+
fi
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
version=$(node -p "require('./package.json').version")
|
|
205
|
+
latest=$(npm view "$name" version 2>/dev/null || echo "")
|
|
206
|
+
already=$(npm view "$name@$version" version 2>/dev/null || echo "")
|
|
207
|
+
|
|
208
|
+
# If the local version has fallen behind the registry (a bump
|
|
209
|
+
# commit that never landed back in the repo), publishing fails
|
|
210
|
+
# with "Cannot implicitly apply the latest tag". Sync first, then
|
|
211
|
+
# let the content comparison decide whether to release at all.
|
|
212
|
+
if [ -z "$already" ] && [ -n "$latest" ]; then
|
|
213
|
+
behind=$(node -e "
|
|
214
|
+
const cmp = (x, y) => { const a = x.split('.').map(Number), b = y.split('.').map(Number); for (let i = 0; i < 3; i++) { if ((a[i]||0) !== (b[i]||0)) return (a[i]||0) - (b[i]||0); } return 0; };
|
|
215
|
+
console.log(cmp(process.argv[1], process.argv[2]) < 0);
|
|
216
|
+
" "$version" "$latest")
|
|
217
|
+
if [ "$behind" = "true" ]; then
|
|
218
|
+
echo "⏫ $name local version $version is behind npm latest $latest — syncing"
|
|
219
|
+
npm version "$latest" --no-git-tag-version --no-workspaces
|
|
220
|
+
version="$latest"
|
|
221
|
+
already="$latest"
|
|
222
|
+
fi
|
|
223
|
+
fi
|
|
224
|
+
|
|
225
|
+
if [ -n "$already" ]; then
|
|
226
|
+
# This exact version is on npm. Publish anyway only if the
|
|
227
|
+
# content changed: npm tarballs are reproducible (normalized
|
|
228
|
+
# mtimes), so pack integrity is a reliable comparison.
|
|
229
|
+
local_integrity=$(npm pack --dry-run --ignore-scripts --json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s)[0].integrity))")
|
|
230
|
+
remote_integrity=$(npm view "$name@$version" dist.integrity 2>/dev/null || echo "")
|
|
231
|
+
|
|
232
|
+
if [ "$local_integrity" = "$remote_integrity" ]; then
|
|
233
|
+
echo "⏭ $name@$version already on npm with identical content — nothing new to release"
|
|
234
|
+
exit 0
|
|
235
|
+
fi
|
|
236
|
+
|
|
237
|
+
# Bumping one patch above `latest` is not enough: `latest` lags
|
|
238
|
+
# any version staged by an interrupted publish, and npm answers
|
|
239
|
+
# a PUT for one of those with E409 "Cannot publish over
|
|
240
|
+
# previously staged version".
|
|
241
|
+
next=$(node "$repo_root/scripts/next-free-version.mjs" "$name" "$version")
|
|
242
|
+
echo "🔼 $name content changed since $version — bumping to $next"
|
|
243
|
+
# --no-workspaces stops npm from doing workspace-aware
|
|
244
|
+
# processing over bun's symlinks, which trips
|
|
245
|
+
# EUNSUPPORTEDPROTOCOL on workspace:* siblings.
|
|
246
|
+
npm version "$next" --no-git-tag-version --no-workspaces
|
|
247
|
+
version="$next"
|
|
248
|
+
fi
|
|
249
|
+
|
|
250
|
+
# Exit 43 for an authorization failure so the loop stops early.
|
|
251
|
+
# A version the registry reserved between the query above and the
|
|
252
|
+
# PUT is still possible, so E409 retries at the next free version
|
|
253
|
+
# rather than failing the package. Five refusals in a row is a
|
|
254
|
+
# problem no amount of bumping fixes.
|
|
255
|
+
log="$RUNNER_TEMP/npm-publish.log"
|
|
256
|
+
attempt=1
|
|
257
|
+
|
|
258
|
+
while :; do
|
|
259
|
+
echo "Publishing $name@$version"
|
|
260
|
+
if npm publish --provenance --access public --ignore-scripts 2>&1 | tee "$log"; then
|
|
261
|
+
exit 0
|
|
262
|
+
fi
|
|
263
|
+
|
|
264
|
+
if grep -qE "npm error code (E401|E404|ENEEDAUTH|EAUTHUNKNOWN|EOTP)" "$log"; then
|
|
265
|
+
exit 43
|
|
266
|
+
fi
|
|
267
|
+
if grep -q "npm error code E403" "$log" && ! grep -qi "cannot publish over" "$log"; then
|
|
268
|
+
exit 43
|
|
269
|
+
fi
|
|
270
|
+
if ! grep -qi "cannot publish over" "$log"; then
|
|
271
|
+
exit 1
|
|
272
|
+
fi
|
|
273
|
+
if [ "$attempt" -ge 5 ]; then
|
|
274
|
+
echo "⚠ npm refused $attempt versions in a row for $name — giving up"
|
|
275
|
+
exit 1
|
|
276
|
+
fi
|
|
277
|
+
|
|
278
|
+
next=$(node "$repo_root/scripts/next-free-version.mjs" "$name" "$version")
|
|
279
|
+
echo "↩ npm has $name@$version reserved — retrying as $next"
|
|
280
|
+
npm version "$next" --no-git-tag-version --no-workspaces
|
|
281
|
+
version="$next"
|
|
282
|
+
attempt=$((attempt + 1))
|
|
283
|
+
done
|
|
284
|
+
)
|
|
285
|
+
rc=$?
|
|
286
|
+
|
|
287
|
+
case "$rc" in
|
|
288
|
+
0) ;;
|
|
289
|
+
42) skipped_builds="$skipped_builds $name" ;;
|
|
290
|
+
43) unauthorized_packages="$unauthorized_packages $name"; auth_broken="1" ;;
|
|
291
|
+
*) failed_packages="$failed_packages $name" ;;
|
|
292
|
+
esac
|
|
293
|
+
done
|
|
294
|
+
|
|
295
|
+
[ -n "$skipped_builds" ] && echo "::warning::Skipped (build failed, not published):$skipped_builds"
|
|
296
|
+
[ -n "$unattempted_packages" ] && echo "⏭ Not attempted (the credential was already rejected):$unattempted_packages"
|
|
297
|
+
|
|
298
|
+
if [ -n "$unauthorized_packages" ]; then
|
|
299
|
+
echo "❌ Not authorized to publish:$unauthorized_packages"
|
|
300
|
+
echo "::error title=npm rejected the publish credential::npm answers an unauthorized PUT with E404 ('could not be found or you do not have permission to access it'), so this reads as a missing package — the packages exist, the write was refused. The NPM_TOKEN secret is read-only, scoped to a different set of packages, or expired; or, with trusted publishing, this repo + workflow is not yet named as the trusted publisher for these packages on npmjs.com."
|
|
301
|
+
fi
|
|
302
|
+
|
|
303
|
+
[ -n "$failed_packages" ] && echo "❌ Failed to publish:$failed_packages"
|
|
304
|
+
|
|
305
|
+
if [ -n "$failed_packages$unauthorized_packages$unattempted_packages" ]; then
|
|
306
|
+
exit 1
|
|
307
|
+
fi
|
|
308
|
+
exit 0
|
|
309
|
+
|
|
310
|
+
- name: Commit version bumps
|
|
311
|
+
if: success()
|
|
312
|
+
run: |
|
|
313
|
+
# Bumps made during publish must land back in the repo so the next run
|
|
314
|
+
# sees them. The workspace:* pinning done for the tarballs must not —
|
|
315
|
+
# restore everything except the "version" field.
|
|
316
|
+
node scripts/restore-pinned-deps.mjs
|
|
317
|
+
git add '{{PACKAGES_GLOB}}/package.json'
|
|
318
|
+
git diff --staged --quiet && echo "No version changes to commit" && exit 0
|
|
319
|
+
git commit -m "chore: bump published package versions [skip ci]"
|
|
320
|
+
git push
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Runs every workspace package that has a test suite, and ships both coverage
|
|
2
|
+
# and test results to Codecov.
|
|
3
|
+
#
|
|
4
|
+
# The matrix is discovered rather than hand-maintained: a first job reads the
|
|
5
|
+
# workspace globs out of the root package.json and emits one matrix entry per
|
|
6
|
+
# package that declares a `test:coverage` (or `test`) script. Adding a package
|
|
7
|
+
# to the repo therefore adds it to CI with no edit here — the failure mode of a
|
|
8
|
+
# hand-written matrix is a package that silently stops being tested.
|
|
9
|
+
#
|
|
10
|
+
# Each entry is expected to produce, relative to its own directory:
|
|
11
|
+
#
|
|
12
|
+
# coverage/lcov.info coverage, uploaded under a Codecov flag named for the package
|
|
13
|
+
# junit.xml test results, ingested by Codecov Test Analytics
|
|
14
|
+
#
|
|
15
|
+
# Neither is required — a package that writes only one still uploads that one.
|
|
16
|
+
|
|
17
|
+
name: tests
|
|
18
|
+
|
|
19
|
+
on:
|
|
20
|
+
push:
|
|
21
|
+
branches: [{{DEFAULT_BRANCH}}]
|
|
22
|
+
pull_request:
|
|
23
|
+
workflow_dispatch:
|
|
24
|
+
|
|
25
|
+
concurrency:
|
|
26
|
+
group: tests-${{ github.ref }}
|
|
27
|
+
cancel-in-progress: true
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
discover:
|
|
31
|
+
name: Discover packages
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
outputs:
|
|
34
|
+
matrix: ${{ steps.list.outputs.matrix }}
|
|
35
|
+
empty: ${{ steps.list.outputs.empty }}
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v4
|
|
38
|
+
|
|
39
|
+
- id: list
|
|
40
|
+
name: List packages with a test script
|
|
41
|
+
run: node scripts/list-test-packages.mjs >> "$GITHUB_OUTPUT"
|
|
42
|
+
|
|
43
|
+
test:
|
|
44
|
+
name: ${{ matrix.flag }}
|
|
45
|
+
needs: discover
|
|
46
|
+
if: needs.discover.outputs.empty != 'true'
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
|
|
49
|
+
# A package whose suite is currently red still uploads its results — that
|
|
50
|
+
# report is the point of Test Analytics — but it does not fail the run.
|
|
51
|
+
# Set `"ci": { "allowFailure": true }` in the package's package.json to
|
|
52
|
+
# opt into that, and drop it once the suite is green.
|
|
53
|
+
continue-on-error: ${{ matrix.allowFailure }}
|
|
54
|
+
|
|
55
|
+
strategy:
|
|
56
|
+
# One package's failure must not cancel the others' uploads.
|
|
57
|
+
fail-fast: false
|
|
58
|
+
matrix: ${{ fromJson(needs.discover.outputs.matrix) }}
|
|
59
|
+
|
|
60
|
+
steps:
|
|
61
|
+
- uses: actions/checkout@v4
|
|
62
|
+
|
|
63
|
+
- uses: oven-sh/setup-bun@v2
|
|
64
|
+
|
|
65
|
+
# --ignore-scripts: a dependency's postinstall has no business running in
|
|
66
|
+
# CI, and some published packages ship a `prepare: "husky install"` that
|
|
67
|
+
# dies with exit 127 when husky is not a dependency of this repo.
|
|
68
|
+
- name: Install dependencies
|
|
69
|
+
run: {{PACKAGE_MANAGER}} install --ignore-scripts
|
|
70
|
+
|
|
71
|
+
# Workspace libraries that siblings consume through their built `dist`
|
|
72
|
+
# must exist before anything imports them. Turbo resolves the dependency
|
|
73
|
+
# order; packages with no build script are a no-op.
|
|
74
|
+
- name: Build workspace dependencies
|
|
75
|
+
run: bunx turbo build --filter=${{ matrix.name }}^... || echo "No buildable dependencies for ${{ matrix.name }}"
|
|
76
|
+
|
|
77
|
+
- name: Run tests
|
|
78
|
+
working-directory: ${{ matrix.dir }}
|
|
79
|
+
run: {{PACKAGE_MANAGER}} run ${{ matrix.script }}
|
|
80
|
+
|
|
81
|
+
# `!cancelled()` rather than `always()`: a cancelled run has nothing worth
|
|
82
|
+
# uploading, but a *failed* one is exactly when the report matters most.
|
|
83
|
+
- name: Upload coverage to Codecov
|
|
84
|
+
if: ${{ !cancelled() }}
|
|
85
|
+
uses: codecov/codecov-action@v5
|
|
86
|
+
with:
|
|
87
|
+
files: ${{ matrix.dir }}/coverage/lcov.info
|
|
88
|
+
flags: ${{ matrix.flag }}
|
|
89
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
90
|
+
# Codecov being down must not turn a green suite red.
|
|
91
|
+
fail_ci_if_error: false
|
|
92
|
+
|
|
93
|
+
- name: Upload test results to Codecov
|
|
94
|
+
if: ${{ !cancelled() }}
|
|
95
|
+
uses: codecov/test-results-action@v1
|
|
96
|
+
with:
|
|
97
|
+
files: ${{ matrix.dir }}/junit.xml
|
|
98
|
+
flags: ${{ matrix.flag }}
|
|
99
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
100
|
+
fail_ci_if_error: false
|