From caef22fb31b00d98bd6806262f103779ce06e2a2 Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Thu, 10 Jul 2025 16:34:08 -0500 Subject: [PATCH] ci: set up an action release pipeline This patch sets up an action release pipeline on merges to main which resembles the gitlab release pipeline. On a merge to main, the workflow kicks in and (assuming everything builds and tests well), updates the `action` branch with the contents of `action-template/`. The workflow leverages the bundled action, which ensures that, at the very least, we cannot produce a tagged release of the action that cannot at least release itself. Note that the action branch contains the binaries built earlier in the workflow to avoid having to run a composite action. --- .github/workflows/release.yml | 105 ++++++++++++++++++++++++++++++++++ README.md | 6 +- action-template/README.md | 79 +++++++++++++++++++++++++ action-template/action.js | 84 +++++++++++++++++++++++++++ action-template/action.yml | 44 ++++++++++++++ cmd_commit.go | 2 + version.go | 2 +- 7 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 action-template/README.md create mode 100644 action-template/action.js create mode 100644 action-template/action.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..61de995 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +# Runs on pushes to main, manages the `action` branch and `action/` tags family. +name: Build and release action + +permissions: + contents: read + packages: read + +on: + push: + branches: + - 'main' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 # needed to make sure we get all tags + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: '1.24' + + - run: go test -v . + + - name: build + run: | + GOOS=linux GOARCH=amd64 go build -buildvcs=false -o ./dist/commit-headless-linux-amd64 . + GOOS=linux GOARCH=arm64 go build -buildvcs=false -o ./dist/commit-headless-linux-arm64 . + + # TODO: Not sure how to determine the current os/arch to select one of the above binaries + # so we're just going to build another one + go build -buildvcs=false -o ./dist/commit-headless . + ./dist/commit-headless version | awk '{print $3}' > ./dist/VERSION.txt + echo "Current version: $(cat ./dist/VERSION.txt)" + + - name: create action branch commit + id: create-commit + run: | + + # Copy the new assets to a temporary location that we can recover later + cp -R dist /tmp/release-assets + + git switch action + + # Remove everything except the git directory + find . -not -path "./.git" -not -path '.' -maxdepth 1 -exec rm -rf {} + + + # Bring back the release assets + mv /tmp/release-assets dist + + # "Restore" the contents of action-template from the previous ref + git restore --source "${{ github.sha }}" action-template/ + + # Copy the contents of action-template to the top of the repository + cp action-template/* . && rm -rf action-template + + # Replace the VERSION in README.md + sed -i "s/%%VERSION%%/$(cat dist/VERSION.txt)/g" README.md + + # Create a commit + # TODO: A merge should have the PR number in the commit headline, if we use the original + # commit message it should back-link + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com>" + git commit \ + --all \ + --message="Update action from ${{ github.sha }}" \ + --allow-empty # sometimes we have nothing to change, so this ensures we can still commit + + REF=$(git rev-parse HEAD) + echo "sha=${REF}" >> $GITHUB_OUTPUT + echo "Created commit ${REF}" + + - name: push commits + id: push-commits + uses: ./ # use the action defined in the action branch + with: + branch: action + command: push + commits: ${{ steps.create-commit.outputs.sha }} + + - name: check release tag + id: check-tag + run: | + TAG="action/v$(cat ./dist/VERSION.txt)" + if git show-ref --tags --verify --quiet "refs/tags/${TAG}"; then + echo "Release tag ${TAG} already exists. Not releasing." + exit 1 + fi + echo "tag=${TAG}" >> $GITHUB_OUTPUT + + - name: make release + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'refs/tags/${{ steps.check-tag.outputs.tag }}', + sha: '${{ steps.push-commits.outputs.pushed_sha }}' + }); diff --git a/README.md b/README.md index 0a0bf9b..47ddbee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # commit-headless -A binary tool and GitHub action for creating signed commits from headless workflows +A binary tool and GitHub Action for creating signed commits from headless workflows + +For the Action, please see [the action branch][action-branch] and the associated `action/` +release tags. `commit-headless` is focused on turning local commits (or dirty files) into signed commits on the remote. It does this via the GitHub GraphQL API, more specifically the [createCommitOnBranch][mutation] @@ -10,6 +13,7 @@ When this API is used with a GitHub App token, the resulting commit will be sign GitHub on behalf of the application. [mutation]: https://docs.github.com/en/graphql/reference/mutations#createcommitonbranch +[action-branch]: https://github.com/DataDog/commit-headless/tree/action ## Usage diff --git a/action-template/README.md b/action-template/README.md new file mode 100644 index 0000000..040aa4b --- /dev/null +++ b/action-template/README.md @@ -0,0 +1,79 @@ +# commit-headless action + +NOTE: This branch contains only the action implementation of `commit-headless`. To view the source +code, see the [main](https://github.com/DataDog/commit-headless/tree/main) branch. + +This action uses `commit-headless` to support creating signed and verified remote commits from a +GitHub action workflow. + +For more details on how `commit-headless` works, check the main branch link above. + +## Usage (commit-headless push) + +If your workflow creates multiple commits and you want to push all of them, you can use +`commit-headless push`: + +``` +- name: Create commits + id: create-commits + run: | + git config --global user.name "A U Thor" + git config --global user.email "author@example.com" + + echo "new file from my bot" >> bot.txt + git add bot.txt && git commit -m"bot commit 1" + + echo "another commit" >> bot.txt + git add bot.txt && git commit -m"bot commit 2" + + # List both commit hashes in reverse order, space separated + echo "commits=\"$(git log "${{ github.sha }}".. --format='%H%x00' | tr '\n' ' ')\"" >> $GITHUB_OUTPUT + +- name: Push commits + uses: DataDog/commit-headless@action/v%%VERSION%% + with: + token: ${{ github.token }} # default + target: ${{ github.repository }} # default + branch: ${{ github.ref_name }} + command: push + commits: "${{ steps.create-commits.outputs.commits }}" +``` + +## Usage (commit-headless commit) + +Some workflows may just have a specific set of files that they change and just want to create a +single commit out of them. For that, you can use `commit-headless commit`: + +``` +- name: Change files + id: change-files + run: | + echo "updating contents of bot.txt" >> bot.txt + + date --rfc-3339=s >> timestamp + + files="bot.txt timestamp" + + # remove an old file if it exists + # commit-headless commit will fail if you attempt to delete a file that doesn't exist on the + # remote (enforced via the GitHub API) + if [[ -f timestamp.old ]]; then + rm timestamp.old + files += " timestamp.old" + fi + + # Record the set of files we want to commit + echo "files=\"${files}\"" >> $GITHUB_OUTPUT + +- name: Create commit + uses: DataDog/commit-headless@action/v%%VERSION%% + with: + token: ${{ github.token }} # default + target: ${{ github.repository }} # default + branch: ${{ github.ref_name }} + author: "A U Thor " # defaults to the github-actions bot account + message: "a commit message" + command: commit + files: "${{ steps.create-commits.outputs.files }}" + force: true # default false, needs to be true to allow deletion +``` diff --git a/action-template/action.js b/action-template/action.js new file mode 100644 index 0000000..ab95552 --- /dev/null +++ b/action-template/action.js @@ -0,0 +1,84 @@ +const childProcess = require('child_process') +const crypto = require('crypto') +const fs = require('fs') +const os = require('os') +const process = require('process') + +function chooseBinary() { + const platform = os.platform() + const arch = os.arch() + + if (platform === 'linux' && arch === 'x64') { + return `dist/commit-headless-linux-amd64` + } + if (platform === 'linux' && arch === 'arm64') { + return `dist/commit-headless-linux-arm64` + } + + console.error(`Unsupported platform (${platform}) and architecture (${arch})`) + process.exit(1) +} + +function main() { + const binary = chooseBinary() + + const cmd = `${__dirname}/${binary}` + + const env = { ...process.env }; + env.HEADLESS_TOKEN = process.env.INPUT_TOKEN; + + const command = process.env.INPUT_COMMAND; + + if (!["commit", "push"].includes(command)) { + console.error(`Unknown command ${command}. Must be one of "commit" or "push".`); + process.exit(1); + } + + let args = [ + command, + "--target", process.env.INPUT_TARGET, + "--branch", process.env.INPUT_BRANCH + ]; + + if (command === "push") { + args.push(...process.env.INPUT_COMMITS.split(/\s+/)); + } else { + const author = process.env["INPUT_AUTHOR"] || ""; + const message = process.env["INPUT_MESSAGE"] || ""; + if(author !== "") { args.push("--author", author) } + if(message !== "") { args.push("--message", message) } + + const force = process.env["INPUT_FORCE"] || "false" + if(!["true", "false"].includes(force.toLowerCase())) { + console.error(`Invalid value for force (${force}). Must be one of true or false.`); + process.exit(1); + } + + if(force.toLowerCase() === "true") { args.push("--force") } + + args.push(...process.env.INPUT_FILES.split(/\s+/)); + } + + const child = childProcess.spawnSync(cmd, args, { + env: env, + stdio: ['ignore', 'pipe', 'inherit'], + }) + + const exitCode = child.status + if (typeof exitCode === 'number') { + if(exitCode === 0) { + const out = child.stdout.toString(); + console.log(`Pushed reference ${out}`); + + const delim = `delim_${crypto.randomUUID()}`; + fs.appendFileSync(process.env.GITHUB_OUTPUT, `pushed_ref<<${delim}${os.EOL}${out}${os.EOL}${delim}`, { encoding: "utf8" }); + } + + process.exit(exitCode) + } + process.exit(1) +} + +if (require.main === module) { + main() +} diff --git a/action-template/action.yml b/action-template/action.yml new file mode 100644 index 0000000..e44645e --- /dev/null +++ b/action-template/action.yml @@ -0,0 +1,44 @@ +name: Create signed commits out of local commits or a set of changed files. + +description: | + This GitHub Action was built specifically to simplify creating signed and verified commits on GitHub. + + The created commits will be signed, and committer and author attribution will be the owner of the + token that was used to create the commit. This is part of the GitHub API and cannot be changed. + However, the original commit author and message will be retained as a "Co-authored-by" trailer and + the message body, respectively. +inputs: + token: + description: 'GitHub token' + required: true + default: ${{ github.token }} + target: + description: 'Target owner/repository' + required: true + default: ${{ github.repository }} + branch: + description: 'Target branch name' + required: true + command: + description: 'Command to run. One of "commit" or "push"' + required: true + commits: + description: 'For push, the list of commit hashes to push, oldest first' + files: + description: 'For commit, the list of files to include in the commit' + force: + description: 'For commit, set to true to support file deletion' + default: false + author: + description: 'For commit, the commit author' + default: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' + message: + description: 'For commit, the commit message' + +outputs: + pushed_sha: + description: 'Commit hash of the last commit created' + +runs: + using: 'node20' + main: 'action.js' diff --git a/cmd_commit.go b/cmd_commit.go index 562e58e..444c078 100644 --- a/cmd_commit.go +++ b/cmd_commit.go @@ -62,6 +62,8 @@ func (c *CommitCmd) Run() error { rootfs := os.DirFS(".") for _, path := range c.Files { + path = strings.TrimPrefix(path, "./") + fp, err := rootfs.Open(path) if errors.Is(err, fs.ErrNotExist) { if !c.Force { diff --git a/version.go b/version.go index 7ba826e..3d18682 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -const VERSION = "0.3.0" +const VERSION = "0.4.0"