Skip to content

Commit 10284fa

Browse files
committed
ci: set up an action release pipeline (#17)
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.
1 parent 1d22641 commit 10284fa

File tree

7 files changed

+321
-2
lines changed

7 files changed

+321
-2
lines changed

.github/workflows/release.yml

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Runs on pushes to main, manages the `action` branch and `action/` tags family.
2+
name: Build and release action
3+
4+
permissions:
5+
contents: write
6+
7+
on:
8+
push:
9+
branches:
10+
- 'main'
11+
12+
jobs:
13+
release:
14+
runs-on: ubuntu-latest
15+
16+
steps:
17+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
18+
with:
19+
fetch-depth: 0 # needed to make sure we get all tags
20+
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
21+
with:
22+
go-version: '1.24'
23+
24+
- run: go test -v .
25+
26+
- name: build
27+
run: |
28+
GOOS=linux GOARCH=amd64 go build -buildvcs=false -o ./dist/commit-headless-linux-amd64 .
29+
GOOS=linux GOARCH=arm64 go build -buildvcs=false -o ./dist/commit-headless-linux-arm64 .
30+
31+
# TODO: Not sure how to determine the current os/arch to select one of the above binaries
32+
# so we're just going to build another one
33+
go build -buildvcs=false -o ./dist/commit-headless .
34+
./dist/commit-headless version | awk '{print $3}' > ./dist/VERSION.txt
35+
echo "Current version: $(cat ./dist/VERSION.txt)"
36+
37+
- name: create action branch commit
38+
id: create-commit
39+
run: |
40+
41+
# Copy the new assets to a temporary location that we can recover later
42+
cp -R dist /tmp/release-assets
43+
44+
git switch action
45+
46+
# Remove everything except the git directory
47+
find . -not -path "./.git" -not -path '.' -maxdepth 1 -exec rm -rf {} +
48+
49+
# Bring back the release assets
50+
mv /tmp/release-assets dist
51+
52+
# "Restore" the contents of action-template from the previous ref
53+
git restore --source "${{ github.sha }}" action-template/
54+
55+
# Copy the contents of action-template to the top of the repository
56+
cp action-template/* . && rm -rf action-template
57+
58+
# Replace the VERSION in README.md
59+
sed -i "s/%%VERSION%%/$(cat dist/VERSION.txt)/g" README.md
60+
61+
git add --all
62+
63+
echo "Changes to commit.."
64+
git status
65+
66+
# Create a commit
67+
# TODO: A merge should have the PR number in the commit headline, if we use the original
68+
# commit message it should back-link
69+
git config user.name "github-actions[bot]"
70+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com>"
71+
git commit \
72+
--message="Update action from ${{ github.sha }}" \
73+
--allow-empty # sometimes we have nothing to change, so this ensures we can still commit
74+
75+
REF=$(git rev-parse HEAD)
76+
echo "sha=${REF}" >> $GITHUB_OUTPUT
77+
echo "Created commit ${REF}"
78+
79+
- name: push commits
80+
id: push-commits
81+
uses: ./ # use the action defined in the action branch
82+
with:
83+
branch: action
84+
command: push
85+
commits: ${{ steps.create-commit.outputs.sha }}
86+
87+
- name: check release tag
88+
id: check-tag
89+
run: |
90+
TAG="action/v$(cat ./dist/VERSION.txt)"
91+
if git show-ref --tags --verify --quiet "refs/tags/${TAG}"; then
92+
echo "Release tag ${TAG} already exists. Not releasing."
93+
exit 1
94+
fi
95+
echo "tag=${TAG}" >> $GITHUB_OUTPUT
96+
97+
- name: make release
98+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
99+
with:
100+
script: |
101+
github.rest.git.createRef({
102+
owner: context.repo.owner,
103+
repo: context.repo.repo,
104+
ref: 'refs/tags/${{ steps.check-tag.outputs.tag }}',
105+
sha: '${{ steps.push-commits.outputs.pushed_ref }}'
106+
});

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# commit-headless
22

3-
A binary tool and GitHub action for creating signed commits from headless workflows
3+
A binary tool and GitHub Action for creating signed commits from headless workflows
4+
5+
For the Action, please see [the action branch][action-branch] and the associated `action/`
6+
release tags.
47

58
`commit-headless` is focused on turning local commits (or dirty files) into signed commits on the
69
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
1013
GitHub on behalf of the application.
1114

1215
[mutation]: https://docs.github.com/en/graphql/reference/mutations#createcommitonbranch
16+
[action-branch]: https://github.com/DataDog/commit-headless/tree/action
1317

1418
## Usage
1519

action-template/README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# commit-headless action
2+
3+
NOTE: This branch contains only the action implementation of `commit-headless`. To view the source
4+
code, see the [main](https://github.com/DataDog/commit-headless/tree/main) branch.
5+
6+
This action uses `commit-headless` to support creating signed and verified remote commits from a
7+
GitHub action workflow.
8+
9+
For more details on how `commit-headless` works, check the main branch link above.
10+
11+
## Usage (commit-headless push)
12+
13+
If your workflow creates multiple commits and you want to push all of them, you can use
14+
`commit-headless push`:
15+
16+
```
17+
- name: Create commits
18+
id: create-commits
19+
run: |
20+
git config --global user.name "A U Thor"
21+
git config --global user.email "[email protected]"
22+
23+
echo "new file from my bot" >> bot.txt
24+
git add bot.txt && git commit -m"bot commit 1"
25+
26+
echo "another commit" >> bot.txt
27+
git add bot.txt && git commit -m"bot commit 2"
28+
29+
# List both commit hashes in reverse order, space separated
30+
echo "commits=\"$(git log "${{ github.sha }}".. --format='%H%x00' | tr '\n' ' ')\"" >> $GITHUB_OUTPUT
31+
32+
- name: Push commits
33+
uses: DataDog/commit-headless@action/v%%VERSION%%
34+
with:
35+
token: ${{ github.token }} # default
36+
target: ${{ github.repository }} # default
37+
branch: ${{ github.ref_name }}
38+
command: push
39+
commits: "${{ steps.create-commits.outputs.commits }}"
40+
```
41+
42+
## Usage (commit-headless commit)
43+
44+
Some workflows may just have a specific set of files that they change and just want to create a
45+
single commit out of them. For that, you can use `commit-headless commit`:
46+
47+
```
48+
- name: Change files
49+
id: change-files
50+
run: |
51+
echo "updating contents of bot.txt" >> bot.txt
52+
53+
date --rfc-3339=s >> timestamp
54+
55+
files="bot.txt timestamp"
56+
57+
# remove an old file if it exists
58+
# commit-headless commit will fail if you attempt to delete a file that doesn't exist on the
59+
# remote (enforced via the GitHub API)
60+
if [[ -f timestamp.old ]]; then
61+
rm timestamp.old
62+
files += " timestamp.old"
63+
fi
64+
65+
# Record the set of files we want to commit
66+
echo "files=\"${files}\"" >> $GITHUB_OUTPUT
67+
68+
- name: Create commit
69+
uses: DataDog/commit-headless@action/v%%VERSION%%
70+
with:
71+
token: ${{ github.token }} # default
72+
target: ${{ github.repository }} # default
73+
branch: ${{ github.ref_name }}
74+
author: "A U Thor <[email protected]>" # defaults to the github-actions bot account
75+
message: "a commit message"
76+
command: commit
77+
files: "${{ steps.create-commits.outputs.files }}"
78+
force: true # default false, needs to be true to allow deletion
79+
```

action-template/action.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
const childProcess = require('child_process')
2+
const crypto = require('crypto')
3+
const fs = require('fs')
4+
const os = require('os')
5+
const process = require('process')
6+
7+
function chooseBinary() {
8+
const platform = os.platform()
9+
const arch = os.arch()
10+
11+
if (platform === 'linux' && arch === 'x64') {
12+
return `dist/commit-headless-linux-amd64`
13+
}
14+
if (platform === 'linux' && arch === 'arm64') {
15+
return `dist/commit-headless-linux-arm64`
16+
}
17+
18+
console.error(`Unsupported platform (${platform}) and architecture (${arch})`)
19+
process.exit(1)
20+
}
21+
22+
function main() {
23+
const binary = chooseBinary()
24+
25+
const cmd = `${__dirname}/${binary}`
26+
27+
const env = { ...process.env };
28+
env.HEADLESS_TOKEN = process.env.INPUT_TOKEN;
29+
30+
const command = process.env.INPUT_COMMAND;
31+
32+
if (!["commit", "push"].includes(command)) {
33+
console.error(`Unknown command ${command}. Must be one of "commit" or "push".`);
34+
process.exit(1);
35+
}
36+
37+
let args = [
38+
command,
39+
"--target", process.env.INPUT_TARGET,
40+
"--branch", process.env.INPUT_BRANCH
41+
];
42+
43+
if (command === "push") {
44+
args.push(...process.env.INPUT_COMMITS.split(/\s+/));
45+
} else {
46+
const author = process.env["INPUT_AUTHOR"] || "";
47+
const message = process.env["INPUT_MESSAGE"] || "";
48+
if(author !== "") { args.push("--author", author) }
49+
if(message !== "") { args.push("--message", message) }
50+
51+
const force = process.env["INPUT_FORCE"] || "false"
52+
if(!["true", "false"].includes(force.toLowerCase())) {
53+
console.error(`Invalid value for force (${force}). Must be one of true or false.`);
54+
process.exit(1);
55+
}
56+
57+
if(force.toLowerCase() === "true") { args.push("--force") }
58+
59+
args.push(...process.env.INPUT_FILES.split(/\s+/));
60+
}
61+
62+
const child = childProcess.spawnSync(cmd, args, {
63+
env: env,
64+
stdio: ['ignore', 'pipe', 'inherit'],
65+
})
66+
67+
const exitCode = child.status
68+
if (typeof exitCode === 'number') {
69+
if(exitCode === 0) {
70+
const out = child.stdout.toString().trim();
71+
console.log(`Pushed reference ${out}`);
72+
73+
const delim = `delim_${crypto.randomUUID()}`;
74+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `pushed_ref<<${delim}${os.EOL}${out}${os.EOL}${delim}`, { encoding: "utf8" });
75+
}
76+
77+
process.exit(exitCode)
78+
}
79+
process.exit(1)
80+
}
81+
82+
if (require.main === module) {
83+
main()
84+
}

action-template/action.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Create signed commits out of local commits or a set of changed files.
2+
3+
description: |
4+
This GitHub Action was built specifically to simplify creating signed and verified commits on GitHub.
5+
6+
The created commits will be signed, and committer and author attribution will be the owner of the
7+
token that was used to create the commit. This is part of the GitHub API and cannot be changed.
8+
However, the original commit author and message will be retained as a "Co-authored-by" trailer and
9+
the message body, respectively.
10+
inputs:
11+
token:
12+
description: 'GitHub token'
13+
required: true
14+
default: ${{ github.token }}
15+
target:
16+
description: 'Target owner/repository'
17+
required: true
18+
default: ${{ github.repository }}
19+
branch:
20+
description: 'Target branch name'
21+
required: true
22+
command:
23+
description: 'Command to run. One of "commit" or "push"'
24+
required: true
25+
commits:
26+
description: 'For push, the list of commit hashes to push, oldest first'
27+
files:
28+
description: 'For commit, the list of files to include in the commit'
29+
force:
30+
description: 'For commit, set to true to support file deletion'
31+
default: false
32+
author:
33+
description: 'For commit, the commit author'
34+
default: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>'
35+
message:
36+
description: 'For commit, the commit message'
37+
38+
outputs:
39+
pushed_sha:
40+
description: 'Commit hash of the last commit created'
41+
42+
runs:
43+
using: 'node20'
44+
main: 'action.js'

cmd_commit.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ func (c *CommitCmd) Run() error {
6262
rootfs := os.DirFS(".")
6363

6464
for _, path := range c.Files {
65+
path = strings.TrimPrefix(path, "./")
66+
6567
fp, err := rootfs.Open(path)
6668
if errors.Is(err, fs.ErrNotExist) {
6769
if !c.Force {

version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
package main
22

3-
const VERSION = "0.3.0"
3+
const VERSION = "0.4.0"

0 commit comments

Comments
 (0)