fix(release): sign and verify release tags (#1902)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
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
|
||||
yBDmsQ68l2LaHc0B/SBq7yAdGJO4zPtjEAE=
|
||||
=R1I9
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -10,8 +11,19 @@ from pathlib import Path
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "release.yml"
|
||||
DIAGNOSTICS_PATH = REPO_ROOT / ".github" / "scripts" / "collect-macos-desktop-diagnostics.sh"
|
||||
PUBLISH_NPM_PATH = REPO_ROOT / "scripts" / "release" / "publish-npm.sh"
|
||||
PUBLIC_KEY_PATH = REPO_ROOT / ".github" / "release-signing-key.asc"
|
||||
RELEASE_PROCESS_PATH = REPO_ROOT / "docs" / "public" / "release-process.md"
|
||||
LINT_WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml"
|
||||
WORKFLOW = WORKFLOW_PATH.read_text()
|
||||
DIAGNOSTICS = DIAGNOSTICS_PATH.read_text()
|
||||
PUBLISH_NPM = PUBLISH_NPM_PATH.read_text()
|
||||
RELEASE_PROCESS = RELEASE_PROCESS_PATH.read_text()
|
||||
LINT_WORKFLOW = LINT_WORKFLOW_PATH.read_text()
|
||||
NORMAL_RELEASE_IF = (
|
||||
"if: ${{ !inputs.dry_run && !inputs.desktop_validation_only "
|
||||
"&& inputs.backfill_tag == '' }}"
|
||||
)
|
||||
|
||||
|
||||
def step_block(name: str) -> str:
|
||||
@@ -35,6 +47,152 @@ def job_block(name: str) -> str:
|
||||
|
||||
|
||||
class ReleaseWorkflowContractTest(unittest.TestCase):
|
||||
def test_normal_release_uses_release_environment_and_requires_main(self) -> None:
|
||||
prepare = job_block("prepare")
|
||||
self.assertIn(
|
||||
"environment: ${{ (!inputs.dry_run && !inputs.desktop_validation_only "
|
||||
"&& inputs.backfill_tag == '') && 'release' || 'release-validation' }}",
|
||||
prepare,
|
||||
)
|
||||
|
||||
guard = step_block("Require main for normal release")
|
||||
self.assertIn(NORMAL_RELEASE_IF, guard)
|
||||
self.assertIn("CURRENT_REF: ${{ github.ref }}", guard)
|
||||
self.assertIn('if [ "$CURRENT_REF" != "refs/heads/main" ]', guard)
|
||||
|
||||
def test_normal_release_validates_signing_identity_after_merge(self) -> None:
|
||||
prepare = job_block("prepare")
|
||||
merge = step_block("Create release PR + squash-merge")
|
||||
public_key = step_block("Validate committed release signing public key")
|
||||
signing = step_block("Import release tag signing key")
|
||||
self.assertIn(NORMAL_RELEASE_IF, public_key)
|
||||
self.assertIn("gpg --batch --with-colons --show-keys .github/release-signing-key.asc", public_key)
|
||||
self.assertIn("PUBLIC_FINGERPRINT", public_key)
|
||||
self.assertIn('echo "fingerprint=$PUBLIC_FINGERPRINT" >> "$GITHUB_OUTPUT"', public_key)
|
||||
self.assertIn(NORMAL_RELEASE_IF, signing)
|
||||
self.assertIn("id: import_release_gpg", signing)
|
||||
self.assertIn(
|
||||
"uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0",
|
||||
signing,
|
||||
)
|
||||
self.assertIn("gpg_private_key: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}", signing)
|
||||
self.assertIn("passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}", signing)
|
||||
self.assertIn("git_user_signingkey: true", signing)
|
||||
self.assertIn("git_tag_gpgsign: true", signing)
|
||||
|
||||
validate = step_block("Validate release tag signing identity")
|
||||
self.assertIn(NORMAL_RELEASE_IF, validate)
|
||||
self.assertIn("EXPECTED_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}", validate)
|
||||
self.assertIn(
|
||||
"COMMITTED_PUBLIC_FINGERPRINT: ${{ steps.committed_release_gpg.outputs.fingerprint }}",
|
||||
validate,
|
||||
)
|
||||
self.assertIn(
|
||||
"IMPORTED_FINGERPRINT: ${{ steps.import_release_gpg.outputs.fingerprint }}",
|
||||
validate,
|
||||
)
|
||||
self.assertIn('if [ -z "$EXPECTED_FINGERPRINT" ]', validate)
|
||||
self.assertIn('if [ "$COMMITTED_PUBLIC_FINGERPRINT" != "$EXPECTED_FINGERPRINT" ]', validate)
|
||||
self.assertIn('if [ "$IMPORTED_FINGERPRINT" != "$COMMITTED_PUBLIC_FINGERPRINT" ]', validate)
|
||||
self.assertIn('if [ "$IMPORTED_FINGERPRINT" != "$EXPECTED_FINGERPRINT" ]', validate)
|
||||
self.assertIn("TAGGER_NAME: ${{ steps.import_release_gpg.outputs.name }}", validate)
|
||||
self.assertIn("TAGGER_EMAIL: ${{ steps.import_release_gpg.outputs.email }}", validate)
|
||||
self.assertIn('git config user.name "$TAGGER_NAME"', validate)
|
||||
self.assertIn('git config user.email "$TAGGER_EMAIL"', validate)
|
||||
self.assertIn('git config user.signingkey "$IMPORTED_FINGERPRINT"', validate)
|
||||
|
||||
tag = step_block("Create and push signed release tag")
|
||||
self.assertIn(NORMAL_RELEASE_IF, tag)
|
||||
self.assertIn('git tag -s "$TAG" -m "release: $NEXT"', tag)
|
||||
self.assertIn('git tag -v "$TAG"', tag)
|
||||
self.assertLess(tag.index('git tag -s "$TAG"'), tag.index('git tag -v "$TAG"'))
|
||||
self.assertLess(tag.index('git tag -v "$TAG"'), tag.index('git push origin "$TAG"'))
|
||||
self.assertNotIn('git tag -a "$TAG"', tag)
|
||||
|
||||
self.assertLess(prepare.index(merge), prepare.index(public_key))
|
||||
self.assertLess(prepare.index(public_key), prepare.index(signing))
|
||||
self.assertLess(prepare.index(signing), prepare.index(validate))
|
||||
self.assertLess(prepare.index(validate), prepare.index(tag))
|
||||
|
||||
def test_committed_release_key_is_one_public_primary_key_without_secret_material(self) -> None:
|
||||
result = subprocess.run(
|
||||
["gpg", "--batch", "--with-colons", "--show-keys", str(PUBLIC_KEY_PATH)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
records = [line.split(":") for line in result.stdout.splitlines() if line]
|
||||
primary_keys = [record for record in records if record[0] == "pub"]
|
||||
secret_keys = [record for record in records if record[0] in {"sec", "ssb"}]
|
||||
primary_fingerprints = [
|
||||
records[index + 1][9]
|
||||
for index, record in enumerate(records[:-1])
|
||||
if record[0] == "pub" and records[index + 1][0] == "fpr"
|
||||
]
|
||||
|
||||
self.assertEqual([], secret_keys)
|
||||
self.assertEqual(1, len(primary_keys))
|
||||
self.assertEqual(1, len(primary_fingerprints))
|
||||
self.assertRegex(primary_fingerprints[0], r"^[A-F0-9]{40}$")
|
||||
|
||||
def test_release_documentation_declares_the_committed_public_key_fingerprint(self) -> None:
|
||||
result = subprocess.run(
|
||||
["gpg", "--batch", "--with-colons", "--show-keys", str(PUBLIC_KEY_PATH)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
records = [line.split(":") for line in result.stdout.splitlines() if line]
|
||||
primary_fingerprints = [
|
||||
records[index + 1][9]
|
||||
for index, record in enumerate(records[:-1])
|
||||
if record[0] == "pub" and records[index + 1][0] == "fpr"
|
||||
]
|
||||
documented_fingerprint = re.search(
|
||||
r"The current key is committed at `\.github/release-signing-key\.asc`; "
|
||||
r"its full fingerprint is `([A-F0-9]{40})`\.",
|
||||
RELEASE_PROCESS,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(documented_fingerprint)
|
||||
self.assertEqual(primary_fingerprints[0], documented_fingerprint.group(1))
|
||||
|
||||
def test_release_contract_ci_runs_when_key_or_release_documentation_changes(self) -> None:
|
||||
for trigger in ("push", "pull_request"):
|
||||
trigger_block = re.search(
|
||||
rf" {trigger}:\n.*?(?=\n [a-z_]+:|\nconcurrency:)",
|
||||
LINT_WORKFLOW,
|
||||
re.DOTALL,
|
||||
)
|
||||
self.assertIsNotNone(trigger_block)
|
||||
self.assertIn('".github/release-signing-key.asc"', trigger_block.group(0))
|
||||
self.assertIn('"docs/public/release-process.md"', trigger_block.group(0))
|
||||
|
||||
def test_tag_push_recovery_recreates_tag_at_logged_merge_commit(self) -> None:
|
||||
tag = step_block("Create and push signed release tag")
|
||||
self.assertIn('MERGE_COMMIT="$(git rev-parse HEAD)"', tag)
|
||||
self.assertIn('echo "Release merge commit: $MERGE_COMMIT"', tag)
|
||||
self.assertIn('git checkout --detach $MERGE_COMMIT', tag)
|
||||
self.assertIn("git tag -s $TAG -m 'release: $NEXT'", tag)
|
||||
self.assertIn('git tag -v $TAG', tag)
|
||||
self.assertIn("matches RELEASE_GPG_FINGERPRINT", tag)
|
||||
self.assertIn('git push origin $TAG', tag)
|
||||
self.assertNotIn('Recover manually: git push origin $TAG', tag)
|
||||
|
||||
def test_npm_publish_preserves_oidc_provenance_for_all_packages(self) -> None:
|
||||
publish_job = job_block("publish-npm")
|
||||
self.assertIn("id-token: write", publish_job)
|
||||
self.assertEqual(PUBLISH_NPM.count("npm publish --access public --provenance"), 2)
|
||||
|
||||
for package in (
|
||||
"@kdlbs/runtime-linux-x64",
|
||||
"@kdlbs/runtime-linux-arm64",
|
||||
"@kdlbs/runtime-darwin-x64",
|
||||
"@kdlbs/runtime-darwin-arm64",
|
||||
"@kdlbs/runtime-win32-x64",
|
||||
):
|
||||
self.assertIn(f'"{package}"', PUBLISH_NPM)
|
||||
|
||||
def test_backfill_tag_input_uses_existing_tag_without_recreating_it(self) -> None:
|
||||
self.assertIn("backfill_tag:", WORKFLOW)
|
||||
self.assertIn("BACKFILL_TAG: ${{ inputs.backfill_tag }}", WORKFLOW)
|
||||
@@ -59,7 +217,10 @@ class ReleaseWorkflowContractTest(unittest.TestCase):
|
||||
|
||||
for name in (
|
||||
"Bump version + generate CHANGELOG (in working tree)",
|
||||
"Create release PR + squash-merge + tag",
|
||||
"Create release PR + squash-merge",
|
||||
"Import release tag signing key",
|
||||
"Validate release tag signing identity",
|
||||
"Create and push signed release tag",
|
||||
):
|
||||
self.assertIn("inputs.backfill_tag == ''", step_block(name))
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
- ".github/scripts/lint-action-pinning_test.py"
|
||||
- ".github/scripts/collect-macos-desktop-diagnostics.sh"
|
||||
- ".github/scripts/release-workflow-contract_test.py"
|
||||
- ".github/release-signing-key.asc"
|
||||
- "docs/public/release-process.md"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
@@ -17,6 +19,8 @@ on:
|
||||
- ".github/scripts/lint-action-pinning_test.py"
|
||||
- ".github/scripts/collect-macos-desktop-diagnostics.sh"
|
||||
- ".github/scripts/release-workflow-contract_test.py"
|
||||
- ".github/release-signing-key.asc"
|
||||
- "docs/public/release-process.md"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -24,8 +24,13 @@ name: release
|
||||
# Each package (kandev + 5 @kdlbs/runtime-* packages) must have this workflow
|
||||
# configured as its trusted publisher on npmjs.com (Settings → Publishing access).
|
||||
#
|
||||
# Required secrets for public releases:
|
||||
# HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key
|
||||
# Required repository secret for public releases:
|
||||
# HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key
|
||||
# Required configuration in the `release` environment:
|
||||
# RELEASE_GPG_PRIVATE_KEY — secret containing the ASCII-armored release tag signing key
|
||||
# RELEASE_GPG_FINGERPRINT — variable containing the key's full fingerprint
|
||||
# Optional configuration in the `release` environment:
|
||||
# RELEASE_GPG_PASSPHRASE — secret containing the private key passphrase
|
||||
# Optional secrets for signed desktop artifacts. If these are incomplete, the
|
||||
# workflow still builds unsigned desktop artifacts and warns in release notes.
|
||||
# APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / KEYCHAIN_PASSWORD — macOS Developer ID signing
|
||||
@@ -68,6 +73,7 @@ jobs:
|
||||
prepare:
|
||||
name: Prepare release (bump version, PR, tag)
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ (!inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '') && 'release' || 'release-validation' }}
|
||||
permissions:
|
||||
contents: write # push release branch + tag
|
||||
pull-requests: write # create + merge release PR (required by main ruleset)
|
||||
@@ -81,6 +87,17 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Require main for normal release
|
||||
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
|
||||
env:
|
||||
CURRENT_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$CURRENT_REF" != "refs/heads/main" ]; then
|
||||
echo "Normal releases must be dispatched from refs/heads/main (got $CURRENT_REF)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
|
||||
with:
|
||||
version: "9.15.9"
|
||||
@@ -314,7 +331,7 @@ jobs:
|
||||
pnpm -C apps install --no-frozen-lockfile
|
||||
git-cliff --tag "$TAG" -o CHANGELOG.md
|
||||
|
||||
- name: Create release PR + squash-merge + tag
|
||||
- name: Create release PR + squash-merge
|
||||
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -349,23 +366,125 @@ jobs:
|
||||
# polling for merge completion before the tag steps below.
|
||||
gh pr merge "$BRANCH" --squash --delete-branch --subject "$MSG" --body ""
|
||||
|
||||
# Pull the squashed commit into local main, then tag from it.
|
||||
# Pull the squashed commit into local main. The signing key is not
|
||||
# imported until all repository-controlled release work is complete.
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
git reset --hard origin/main
|
||||
git tag -a "$TAG" -m "release: $NEXT"
|
||||
|
||||
- name: Validate committed release signing public key
|
||||
id: committed_release_gpg
|
||||
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEY_LISTING="$(gpg --batch --with-colons --show-keys .github/release-signing-key.asc)"
|
||||
SECRET_KEY_RECORDS="$(printf '%s\n' "$KEY_LISTING" | awk -F: '$1 == "sec" || $1 == "ssb" { count++ } END { print count + 0 }')"
|
||||
PUBLIC_KEY_RECORDS="$(printf '%s\n' "$KEY_LISTING" | awk -F: '$1 == "pub" { count++ } END { print count + 0 }')"
|
||||
PRIMARY_FINGERPRINTS="$(printf '%s\n' "$KEY_LISTING" | awk -F: '
|
||||
$1 == "pub" {
|
||||
if (getline && $1 == "fpr") print $10
|
||||
}
|
||||
')"
|
||||
PRIMARY_FINGERPRINT_COUNT="$(printf '%s\n' "$PRIMARY_FINGERPRINTS" | awk 'NF { count++ } END { print count + 0 }')"
|
||||
|
||||
if [ "$SECRET_KEY_RECORDS" -ne 0 ]; then
|
||||
echo ".github/release-signing-key.asc must not contain secret-key material." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PUBLIC_KEY_RECORDS" -eq 0 ]; then
|
||||
echo ".github/release-signing-key.asc must contain one primary public key; none found." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PUBLIC_KEY_RECORDS" -ne 1 ] || [ "$PRIMARY_FINGERPRINT_COUNT" -ne 1 ]; then
|
||||
echo ".github/release-signing-key.asc must contain exactly one primary public key and fingerprint." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PUBLIC_FINGERPRINT="$PRIMARY_FINGERPRINTS"
|
||||
if ! [[ "$PUBLIC_FINGERPRINT" =~ ^[A-F0-9]{40}$ ]]; then
|
||||
echo ".github/release-signing-key.asc contains an invalid primary public-key fingerprint." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "fingerprint=$PUBLIC_FINGERPRINT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Import release tag signing key
|
||||
id: import_release_gpg
|
||||
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
|
||||
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
git_user_signingkey: true
|
||||
git_tag_gpgsign: true
|
||||
|
||||
- name: Validate release tag signing identity
|
||||
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
|
||||
env:
|
||||
EXPECTED_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
COMMITTED_PUBLIC_FINGERPRINT: ${{ steps.committed_release_gpg.outputs.fingerprint }}
|
||||
IMPORTED_FINGERPRINT: ${{ steps.import_release_gpg.outputs.fingerprint }}
|
||||
TAGGER_NAME: ${{ steps.import_release_gpg.outputs.name }}
|
||||
TAGGER_EMAIL: ${{ steps.import_release_gpg.outputs.email }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$EXPECTED_FINGERPRINT" ]; then
|
||||
echo "RELEASE_GPG_FINGERPRINT is required in the release environment." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$COMMITTED_PUBLIC_FINGERPRINT" ]; then
|
||||
echo "The committed release public key did not report a fingerprint." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$COMMITTED_PUBLIC_FINGERPRINT" != "$EXPECTED_FINGERPRINT" ]; then
|
||||
echo "The committed release public key does not match RELEASE_GPG_FINGERPRINT." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$IMPORTED_FINGERPRINT" ]; then
|
||||
echo "The imported release signing key did not report a fingerprint." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$IMPORTED_FINGERPRINT" != "$COMMITTED_PUBLIC_FINGERPRINT" ]; then
|
||||
echo "The imported release signing key does not match the committed release public key." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$IMPORTED_FINGERPRINT" != "$EXPECTED_FINGERPRINT" ]; then
|
||||
echo "The imported release signing key does not match RELEASE_GPG_FINGERPRINT." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$TAGGER_NAME" ] || [ -z "$TAGGER_EMAIL" ]; then
|
||||
echo "The validated release signing key must contain a tagger name and email." >&2
|
||||
exit 1
|
||||
fi
|
||||
git config user.name "$TAGGER_NAME"
|
||||
git config user.email "$TAGGER_EMAIL"
|
||||
git config user.signingkey "$IMPORTED_FINGERPRINT"
|
||||
|
||||
- name: Create and push signed release tag
|
||||
if: ${{ !inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '' }}
|
||||
env:
|
||||
NEXT: ${{ steps.compute.outputs.version }}
|
||||
TAG: ${{ steps.compute.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MERGE_COMMIT="$(git rev-parse HEAD)"
|
||||
echo "Release merge commit: $MERGE_COMMIT"
|
||||
git tag -s "$TAG" -m "release: $NEXT"
|
||||
git tag -v "$TAG"
|
||||
|
||||
# Tag push: retry once for transient network blips. If both attempts
|
||||
# fail, main already has the bumped commit but no tag exists.
|
||||
# Recover by manually pushing the tag from any clone pointing at the
|
||||
# squashed merge commit: git push origin "$TAG"
|
||||
git push origin "$TAG" || {
|
||||
echo "Tag push failed; retrying in 5s..." >&2
|
||||
sleep 5
|
||||
git push origin "$TAG" || {
|
||||
echo "ERROR: Tag push failed twice after PR merge succeeded." >&2
|
||||
echo " main is at $NEXT but the tag was not created on origin." >&2
|
||||
echo " Recover manually: git push origin $TAG" >&2
|
||||
echo " Release merge commit: $MERGE_COMMIT" >&2
|
||||
echo " Recover only with the configured release signing identity:" >&2
|
||||
echo " git checkout --detach $MERGE_COMMIT" >&2
|
||||
echo " git tag -s $TAG -m 'release: $NEXT'" >&2
|
||||
echo " git tag -v $TAG" >&2
|
||||
echo " Confirm the verified full fingerprint matches RELEASE_GPG_FINGERPRINT, then:" >&2
|
||||
echo " git push origin $TAG" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user