Add optional s3-acl input for canned ACLs on uploads

Lets the workflow set, e.g., public-read on the uploaded object so the
HTTPS URL is actually downloadable without further configuration. Empty
default means no ACL is sent — required for modern AWS buckets with
Object Ownership = "Bucket owner enforced" that reject any ACL.

Validates the value against the AWS canned-ACL list at config time so
typos fail before the upload runs. Wires the input through action.yml,
config, and the orchestrator; adds a unit test that the ACL is forwarded
to PutObjectInput when set and omitted when empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leon Mika 2026-05-02 13:59:49 +10:00
parent 78f63e640f
commit 03da0c3e85
6 changed files with 53 additions and 3 deletions

View file

@ -61,6 +61,7 @@ jobs:
| `s3-key` | conditional | — | Object key. Required if `s3-bucket` is set. Supports `{version}` and `{filename}` placeholders. | | `s3-key` | conditional | — | Object key. Required if `s3-bucket` is set. Supports `{version}` and `{filename}` placeholders. |
| `s3-endpoint-url` | no | — | Custom endpoint for S3-compatible storage (MinIO, R2, etc.) | | `s3-endpoint-url` | no | — | Custom endpoint for S3-compatible storage (MinIO, R2, etc.) |
| `s3-region` | no | `us-east-1` | AWS region | | `s3-region` | no | `us-east-1` | AWS region |
| `s3-acl` | no | — | Canned ACL applied to the uploaded object (e.g. `public-read`). Empty = no ACL sent. Modern AWS buckets with Object Ownership = "Bucket owner enforced" reject any ACL — leave this unset for those. |
AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optionally `AWS_SESSION_TOKEN`) are read from the standard environment, **not** from action inputs. AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optionally `AWS_SESSION_TOKEN`) are read from the standard environment, **not** from action inputs.

View file

@ -26,6 +26,7 @@ inputs:
s3-key: { description: "Object key. Supports {version} and {filename} placeholders.", required: false } s3-key: { description: "Object key. Supports {version} and {filename} placeholders.", required: false }
s3-endpoint-url: { description: "Custom endpoint for S3-compatible storage (MinIO, R2, etc.)", required: false } s3-endpoint-url: { description: "Custom endpoint for S3-compatible storage (MinIO, R2, etc.)", required: false }
s3-region: { description: "AWS region", required: false, default: "us-east-1" } s3-region: { description: "AWS region", required: false, default: "us-east-1" }
s3-acl: { description: "Canned ACL applied to the uploaded object (e.g. public-read). Empty = no ACL sent.", required: false }
outputs: outputs:
version: { description: "Resolved version string", value: "${{ steps.run.outputs.version }}" } version: { description: "Resolved version string", value: "${{ steps.run.outputs.version }}" }
@ -58,6 +59,7 @@ runs:
INPUT_S3_KEY: ${{ inputs.s3-key }} INPUT_S3_KEY: ${{ inputs.s3-key }}
INPUT_S3_ENDPOINT_URL: ${{ inputs.s3-endpoint-url }} INPUT_S3_ENDPOINT_URL: ${{ inputs.s3-endpoint-url }}
INPUT_S3_REGION: ${{ inputs.s3-region }} INPUT_S3_REGION: ${{ inputs.s3-region }}
INPUT_S3_ACL: ${{ inputs.s3-acl }}
run: | run: |
set -euo pipefail set -euo pipefail
go install "lmika.dev/${{ github.action_repository }}/cmd/wails-release@${{ github.action_ref }}" go install "lmika.dev/${{ github.action_repository }}/cmd/wails-release@${{ github.action_ref }}"

View file

@ -169,7 +169,7 @@ func run(ctx context.Context) error {
} }
key := upload.RenderKey(cfg.S3Key, resolvedVersion, artifactName) key := upload.RenderKey(cfg.S3Key, resolvedVersion, artifactName)
s3URL, err = upload.Upload(ctx, client, upload.Opts{ s3URL, err = upload.Upload(ctx, client, upload.Opts{
Bucket: cfg.S3Bucket, Key: key, FilePath: zipPath, Bucket: cfg.S3Bucket, Key: key, FilePath: zipPath, ACL: cfg.S3ACL,
}) })
if err != nil { if err != nil {
return err return err

View file

@ -28,6 +28,7 @@ type Config struct {
S3Key string S3Key string
S3EndpointURL string S3EndpointURL string
S3Region string S3Region string
S3ACL string
} }
// Load reads the action's INPUT_* environment variables. // Load reads the action's INPUT_* environment variables.
@ -51,6 +52,7 @@ func Load(get func(string) string) *Config {
S3Key: get("INPUT_S3_KEY"), S3Key: get("INPUT_S3_KEY"),
S3EndpointURL: get("INPUT_S3_ENDPOINT_URL"), S3EndpointURL: get("INPUT_S3_ENDPOINT_URL"),
S3Region: getOr(get, "INPUT_S3_REGION", "us-east-1"), S3Region: getOr(get, "INPUT_S3_REGION", "us-east-1"),
S3ACL: get("INPUT_S3_ACL"),
} }
return c return c
} }
@ -115,6 +117,14 @@ func (c *Config) Validate() error {
if c.S3Bucket != "" && c.S3Key == "" { if c.S3Bucket != "" && c.S3Key == "" {
return fmt.Errorf("s3-bucket is set but s3-key is empty") return fmt.Errorf("s3-bucket is set but s3-key is empty")
} }
if c.S3ACL != "" {
switch c.S3ACL {
case "private", "public-read", "public-read-write", "authenticated-read",
"aws-exec-read", "bucket-owner-read", "bucket-owner-full-control":
default:
return fmt.Errorf("s3-acl must be one of private, public-read, public-read-write, authenticated-read, aws-exec-read, bucket-owner-read, bucket-owner-full-control (got %q)", c.S3ACL)
}
}
return nil return nil
} }

View file

@ -9,6 +9,7 @@ import (
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config" awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
) )
// PutObjectAPI is the subset of *s3.Client used here. Tests inject a fake. // PutObjectAPI is the subset of *s3.Client used here. Tests inject a fake.
@ -21,6 +22,10 @@ type Opts struct {
Bucket string Bucket string
Key string Key string
FilePath string FilePath string
// ACL, if set, is applied to the uploaded object as a canned ACL
// (e.g. "public-read"). Empty means no ACL is sent — required for
// buckets with Object Ownership = "Bucket owner enforced".
ACL string
} }
// RenderKey substitutes {version} and {filename} placeholders. // RenderKey substitutes {version} and {filename} placeholders.
@ -37,11 +42,15 @@ func Upload(ctx context.Context, c PutObjectAPI, o Opts) (string, error) {
} }
defer f.Close() defer f.Close()
if _, err := c.PutObject(ctx, &s3.PutObjectInput{ in := &s3.PutObjectInput{
Bucket: aws.String(o.Bucket), Bucket: aws.String(o.Bucket),
Key: aws.String(o.Key), Key: aws.String(o.Key),
Body: f, Body: f,
}); err != nil { }
if o.ACL != "" {
in.ACL = types.ObjectCannedACL(o.ACL)
}
if _, err := c.PutObject(ctx, in); err != nil {
return "", fmt.Errorf("s3 put: %w", err) return "", fmt.Errorf("s3 put: %w", err)
} }
return fmt.Sprintf("s3://%s/%s", o.Bucket, o.Key), nil return fmt.Sprintf("s3://%s/%s", o.Bucket, o.Key), nil

View file

@ -104,6 +104,34 @@ func TestUpload_CallsPutObjectWithCorrectInputs(t *testing.T) {
} }
} }
func TestUpload_ForwardsACLWhenSet(t *testing.T) {
tmp := writeTempFile(t, "hello")
c := &fakePut{}
if _, err := upload.Upload(context.Background(), c, upload.Opts{
Bucket: "b", Key: "k", FilePath: tmp, ACL: "public-read",
}); err != nil {
t.Fatalf("unexpected: %v", err)
}
if string(c.calls[0].ACL) != "public-read" {
t.Fatalf("ACL got %q want public-read", c.calls[0].ACL)
}
}
func TestUpload_OmitsACLWhenEmpty(t *testing.T) {
tmp := writeTempFile(t, "hello")
c := &fakePut{}
if _, err := upload.Upload(context.Background(), c, upload.Opts{
Bucket: "b", Key: "k", FilePath: tmp,
}); err != nil {
t.Fatalf("unexpected: %v", err)
}
if c.calls[0].ACL != "" {
t.Fatalf("ACL got %q want empty (no ACL sent)", c.calls[0].ACL)
}
}
func TestUpload_FailsWhenFileMissing(t *testing.T) { func TestUpload_FailsWhenFileMissing(t *testing.T) {
_, err := upload.Upload(context.Background(), &fakePut{}, upload.Opts{ _, err := upload.Upload(context.Background(), &fakePut{}, upload.Opts{
Bucket: "b", Bucket: "b",