Compare commits
2 commits
d8f5309a73
...
03da0c3e85
| Author | SHA1 | Date | |
|---|---|---|---|
| 03da0c3e85 | |||
| 78f63e640f |
6 changed files with 110 additions and 3 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
|
|
|
||||||
|
|
@ -169,11 +169,12 @@ 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
|
||||||
}
|
}
|
||||||
|
fmt.Println("Uploaded to:", upload.HTTPSURL(cfg.S3Bucket, key, cfg.S3Region, cfg.S3EndpointURL))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Outputs (fixed order so partial-write failures are reproducible)
|
// 8. Outputs (fixed order so partial-write failures are reproducible)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,16 +42,33 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTPSURL returns the HTTPS form of an upload location. For AWS S3 it uses
|
||||||
|
// the regional virtual-hosted form; for a custom endpoint it appends the
|
||||||
|
// bucket and key path-style, matching how NewClient configures the client.
|
||||||
|
//
|
||||||
|
// The bucket may still be private — this is purely the URL where the object
|
||||||
|
// would be served if it (or its bucket) were publicly readable.
|
||||||
|
func HTTPSURL(bucket, key, region, endpointURL string) string {
|
||||||
|
if endpointURL != "" {
|
||||||
|
return strings.TrimRight(endpointURL, "/") + "/" + bucket + "/" + key
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", bucket, region, key)
|
||||||
|
}
|
||||||
|
|
||||||
// WriteFileForTest is exposed for use from this package's tests.
|
// WriteFileForTest is exposed for use from this package's tests.
|
||||||
func WriteFileForTest(path string, b []byte) error {
|
func WriteFileForTest(path string, b []byte) error {
|
||||||
return os.WriteFile(path, b, 0o600)
|
return os.WriteFile(path, b, 0o600)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,49 @@ func TestRenderKey(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHTTPSURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
bucket, key string
|
||||||
|
region string
|
||||||
|
endpoint string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "aws regional virtual-hosted",
|
||||||
|
bucket: "my-bucket", key: "releases/1.2.3/App.app.zip",
|
||||||
|
region: "ap-southeast-2",
|
||||||
|
want: "https://my-bucket.s3.ap-southeast-2.amazonaws.com/releases/1.2.3/App.app.zip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "aws us-east-1 still uses regional form",
|
||||||
|
bucket: "my-bucket", key: "k",
|
||||||
|
region: "us-east-1",
|
||||||
|
want: "https://my-bucket.s3.us-east-1.amazonaws.com/k",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom endpoint path-style",
|
||||||
|
bucket: "releases", key: "myapp/1.0.0/App.app.zip",
|
||||||
|
region: "auto", endpoint: "https://my-minio.example.com",
|
||||||
|
want: "https://my-minio.example.com/releases/myapp/1.0.0/App.app.zip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom endpoint with trailing slash is normalised",
|
||||||
|
bucket: "b", key: "k",
|
||||||
|
region: "auto", endpoint: "https://example.com/",
|
||||||
|
want: "https://example.com/b/k",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := upload.HTTPSURL(c.bucket, c.key, c.region, c.endpoint)
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("got %q\nwant %q", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type fakePut struct {
|
type fakePut struct {
|
||||||
calls []*s3.PutObjectInput
|
calls []*s3.PutObjectInput
|
||||||
}
|
}
|
||||||
|
|
@ -61,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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue